Skip to content

feat(spec)!: a locale must be a BCP-47 tag — zh_CN, padded values and language names are refused - #25983

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-dmzapg
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-dmzapg

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread
Fixes #25822

Clause-②: no (narrowing)
Metadata-protocol: narrowed

What changes

LocaleSchema (packages/spec/src/system/translation.zod.ts) was a bare z.string(). A translator or AI author who wrote zh_CN, a padded value or not a locale!! got a green save, and the strings were stored under a locale no session ever asks for. The resolvers fold case and nothing else, so those strings never rendered.

  • Schema refusal. LocaleSchema now accepts only a tag-shaped value: a 2–8 letter language subtag, then hyphen-separated 1–8 letter-or-digit subtags. This is the same pattern SYS_USER_LOCALE_TAG_PATTERN uses for a user's own locale column. The refusal is one invalid_format issue whose message names the shape and the release. Case is not judged: zh-cn still parses, because every resolver folds case.
  • Data at rest. ADR-0087 D2 conversion translation-locale-tag-canonicalized (retired from the load path from day one, retiredAfter: '18.1.0', order 76) rewrites the two spellings that have one tag they can have meant: _ as the separator, and surrounding whitespace. Any other value is left as stored and reported as a TODO. So is a bundle key that would land on a tag the entry already holds with different strings. An identical twin is merged.
  • D3 entry translation-locale-non-tag-refused carries the judgment for os migrate meta.
  • Changeset .changeset/25822-locale-tag-shape.md (@objectstack/spec minor) with the FROM → TO mapping and both declaration lines.

Every consumer of LocaleSchema, and whether it is in scope

Site In scope? What happens
TranslationItemSchema.locale (metadata save door, defineTranslation) yes refused at locale; the card's reproduction
TranslationBundleSchema / PlatformTranslationBundleSchema map key (defineTranslationBundle, stack.translations) yes refused as invalid_key at the key; the error map surfaces the nested message
TranslationConfigSchema defaultLocale / supportedLocales / fallbackLocale yes refused at the value; the conversion covers i18n.*
data/seed.zod.ts Seed.locale[] yes refused at the value; the conversion covers data[].locale[]
data/seed-loader.zod.ts SeedLoaderConfig.locale yes, by reach a runtime request, not stored. Its one producer is AppPlugin.resolveSeedLocale(), which reads i18n.defaultLocale, itself narrowed above, and converted at artifact load (convertArtifactAtLoad runs over the definition)
conversions/registry.ts comments at the submitLabel and subCaption walks ("LocaleSchema is an open string") no edit still true in the sense they use it: the locale keys are an open set and cannot be enumerated, so judging the bundle structurally stays right
packages/lint/src/lint-liveness-properties.ts translation bundle walk no edit walks every key under the never-throws contract; unaffected by the shape
ui/packages/app-shell/.../metadata-admin/clientValidation.ts no edit its comment describes the bundle-vs-item contract, not the locale shape; the server door refuses, which is the contract

Mechanism assumptions, re-measured on 0c372288

  • Zero non-canonical locales in non-test source: holds. A git grep over locale/defaultLocale/fallbackLocale literals, supportedLocales arrays and underscore bundle keys across packages/ and examples/ finds none that parse through LocaleSchema. The non-tag hits are a user's sys_user.locale, an email-template locale and the i18n label resolver's inputs, none of which use this schema.
  • Syntax-only check fits: holds (shared/value-domain.zod.ts records why a registry check was dropped).
  • Stored deployed rows: cannot be measured from the repository. The conversion covers stored translation rows (applyConversionsToStoredItem reaches them through STORED_ONLY_COLLECTIONS) and built artifacts inside their declared-floor window.
  • Landing point: as ordered, translation.zod.ts plus one MAJOR_18 conversion. No other producer needed a fix.

Deviations from the claim's file surface

Two files outside translation.zod.ts (+ tests), registry.ts, generated artefacts, one changeset, each forced by a red test:

  • packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts, the D3 entry: migrations.test.ts reds "from protocol 18 on, every graduated D2 conversion is named by a D3 entry of its own step (ruling B)" without it (measured).
  • packages/spec/src/shared/retired-key-removal-version.pin.test.ts: one JOIN row. The pin reds every entry retired after the newest published stable release that has no row.

Verification (head 3e367787)

  • pnpm --filter @objectstack/spec build, then check:generated: 14/14 artifacts up to date.
  • pnpm --filter @objectstack/spec typecheck: exit 0. pnpm --filter @objectstack/spec test: 722 files, 21903 tests passed.
  • New pins in translation.test.ts: tags accepted, ten non-tags refused as invalid_format naming the release, refusal at the item, bundle key, platform bundle key and i18n config, and the conversion's TODOs and twin merge. The conversion fixture runs in conversions.test.ts (7 notices).
  • Derived local gates (dispatch-gates.mjs --commands): 92/92 run, all exit 0. Five exited 3 (prerequisite not built) on the first pass and 0 after the dependency closure was built. --ran reconciliation: 92 derived, 92 run, 0 NOT-MEASURED.
  • Dogfood battery (pnpm --filter @objectstack/dogfood test under the verify lock, after building the dogfood closure): VERDICT command-exit 1. Test Files 1 failed, 295 passed, 3 skipped (299); Tests 1 failed, 2522 passed, 18 skipped (2541). The one failure is not this diff's: test/authz-probe-blind-spot.test.ts > packages/runtime/src/domains/i18n.ts expects 3 occurrences of the handleI18nRequest control and reads 4. 69fa23d60 (fix(runtime): GET /i18n/translations/:locale carries an ETag and Cache-Control: private, no-cache, and a matching If-None-Match is answered 304 (#25785) #25956, already on main) added the fourth. This diff touches no runtime file.

Acceptance notes

  • Case-insensitive acceptance (zh-cn) is deliberate: the resolvers fold case (resolveLocale in core's memory i18n, the metadata-protocol seed loader), so refusing it would refuse a locale that works.
  • A non-tag i18n.defaultLocale that reaches AppPlugin without passing defineStack and outside the artifact window now fails the boot-time SeedLoaderRequestSchema.parse loudly instead of filtering seeds on a locale no dataset can name.

Generated by Claude Code

A translation item, a bundle's locale key, the stack's i18n locales and a
seed dataset's locale scope accepted any string, so zh_CN or a padded value
saved green under a locale no session asks for. The ADR-0087 D2 conversion
translation-locale-tag-canonicalized rewrites the underscore and whitespace
spellings in stored rows and built artifacts; the rest is reported as a TODO.

Claude-Session: https://claude.ai/code/session_01Do245n6wxzetD6poZNSdeM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 17 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/ai/agents.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/api/client-sdk.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/api/error-catalog.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/data-modeling/index.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/deployment/environment-variables.mdx (via fallbackLocale (literal, a string literal in apply))
  • content/docs/deployment/validating-metadata.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/getting-started/examples.mdx (via i18n.supportedLocales (literal, a string literal in apply))
  • content/docs/kernel/services-checklist.mdx (via defaultLocale (literal, a string literal in apply), fallbackLocale (literal, a string literal in apply))
  • content/docs/permissions/authorization.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/protocol/kernel/i18n-standard.mdx (via LocaleSchema (symbol, a top-level const), defaultLocale (literal, a string literal in apply), fallbackLocale (literal, a string literal in apply))
  • content/docs/ui/index.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/ui/translations.mdx (via defaultLocale (literal, a string literal in apply), fallbackLocale (literal, a string literal in apply))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object translationLocaleTagCanonicalized), retiredFromLoadPath (symbol, a field of const object translationLocaleTagCanonicalized), crm_lead (literal, a string literal in fixture))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 144 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a4eae06499a8f0377960bfc9c98daced5cf19258 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bdce2f25ec6274740bc3d40393edd9acc1870954 — the merge of head 2f0b44d58376c8b5377f865e09570a2dc744a844 into base a4eae06499a8f0377960bfc9c98daced5cf19258, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bdce2f25ec6274740bc3d40393edd9acc1870954 && git checkout bdce2f25ec6274740bc3d40393edd9acc1870954
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a4eae06499a8f0377960bfc9c98daced5cf19258 2f0b44d58376c8b5377f865e09570a2dc744a844 && git checkout -B drift-repro a4eae06499a8f0377960bfc9c98daced5cf19258 && git merge --no-ff 2f0b44d58376c8b5377f865e09570a2dc744a844

node scripts/docs-audit/affected-docs.mjs --json a4eae06499a8f0377960bfc9c98daced5cf19258

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a4eae06499a8f0377960bfc9c98daced5cf19258 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3e367787ebe9b2c60d31244dd43e8601b3684b65
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts, packages/spec/src/system/translation.zod.ts + packages/spec/src/system/translation.test.ts, packages/spec/src/shared/retired-key-removal-version.pin.test.ts, .changeset/25822-locale-tag-shape.md, packages/spec/src/conversions/types.ts, packages/spec/src/conversions/walk.ts, packages/spec/src/conversions/stored.ts, packages/spec/src/migrations/types.ts, packages/spec/src/shared/value-domain.zod.ts, packages/spec/src/data/seed.zod.ts, packages/spec/src/data/seed-loader.zod.ts, packages/platform-objects/src/identity/sys-user.object.ts, packages/core/src/fallbacks/memory-i18n.ts, packages/core/src/fallbacks/authored-translation-sync.ts, packages/metadata/src/loaders/database-loader.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol-save-door-refusals.ts

Narrowing face: the PR body and the changeset both declare Clause-②: no (narrowing), so every non-prose path of the diff is in scope; nothing is a remainder for the lane seat.

① Derived judgments

  1. Accept set of LocaleSchema — was any string; now ^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$, byte-identical to SYS_USER_LOCALE_TAG_PATTERN (packages/platform-objects/src/identity/sys-user.object.ts:33). Refused from now on: an underscore separator, surrounding or inner whitespace, the empty string, a language name, a one-letter language subtag (BCP-47 private-use x-… and grandfathered i-… tags), any subtag over 8 characters. Right. A session's sys_user.locale cannot name any of those either, so no translation stored under them was ever askable. Case is not judged; resolveLocale (memory-i18n.ts:47) folds case, so zh-cn resolves and refusing it would refuse a working locale. Right. Syntax only: value-domain.zod.ts:82 records why no membership domain exists. Right.

  2. Reach of the narrowing — re-measured on main: the complete consumer set of LocaleSchema is TranslationItemSchema.locale, the TranslationBundleSchema and PlatformTranslationBundleSchema map keys, TranslationConfigSchema.defaultLocale / supportedLocales / fallbackLocale, Seed.locale[] (data/seed.zod.ts:213) and SeedLoaderConfig.locale (data/seed-loader.zod.ts:285). The PR body names every one and states its scope; all six narrow through the one schema edit. PackageLocaleSchema (marketplace) is its own schema and is rightly untouched. The three readers the premise check named (registry.ts walks, lint-liveness, ui clientValidation) judge bundle shape structurally, not by key spelling, so they need no edit. Right, with one wording nit: the registry.ts comments at the submitLabel and subCaption walks still say "LocaleSchema is an open string"; the reasoning they carry (the key set cannot be enumerated) still holds, but the phrase is now imprecise. Not blocking.

  3. Error envelope — one invalid_format issue at path locale (item door) or one invalid_key at the offending key (bundle doors), message naming the shape and the release, no tracker number in the runtime string. The save door maps schema issues to 422 INVALID_METADATA with issues[{path, message, code}] through the existing protocol-save-door-refusals.ts plumbing, which this diff does not touch, so the card's expected envelope is met at the item door and at defineTranslation / defineTranslationBundle and on the bundle map key, as triage 6399808976 ordered. Pinned at the schema layer (translation.test.ts: ten refused values, four doors). Right. No HTTP-level pin was added; acceptable, since the door mapping is pre-existing and unchanged.

  4. Data at rest — D2 translation-locale-tag-canonicalized, toMajor: 18, order 76 (orders 1–75 are taken; 76 is the next free slot), retiredFromLoadPath: true, retiredAfter: '18.1.0' = the package's current stable label, exactly the rule in conversions/types.ts (sibling precedent at the same value). It rewrites only the two spellings with one tag they can have meant (_ as separator, surrounding whitespace) and only when the result passes LocaleSchema; everything else is left as stored and reported through context.reportTodo. Bundle-key renames go through renameKey (walk.ts:675), which merges an identical twin and returns null on differing strings, which the conversion turns into a TODO rather than picking a winner. Reach: translations[] item locale and bundle keys, i18n.*, data[].locale[]. A legacy item without locale is not misread as a bundle (letters-only keys skipped). Right. All three stored-row seams (database-loader.ts:884, authored-translation-sync.ts:172, protocol.ts:6447) replay the chain with retired entries and none re-parses the row through the schema afterwards, so a residual non-tag row keeps loading as stored, is listed by os migrate meta --stored, and is refused at its next save, exactly as the D3 entry states. Right. Deployed rows cannot be measured from the repository; conversion plus TODO listing is the coverage. Non-test source re-measured on main: zero underscore, padded or empty locale literals feed the schema. Fixture: 7 notices, covers every reach. Right.

  5. D3 entry translation-locale-non-tag-refused — matches SemanticMigration, conversionIds joins the D2 id, surface carries no backticks (rendered in a code span), replacement does (sibling practice). Right. The JOIN row in retired-key-removal-version.pin.test.ts is owed for an entry retired after the newest stable; present. Right.

  6. Public surface — no export added or removed (LOCALE_TAG_SHAPE and LOCALE_TAG_REFUSAL are module-private); the Locale type stays string; .describe() unchanged, so no generated artifact moves. The head's TypeScript Type Check job (api-surface, docs, authorable-surface gates) is green. Right.

  7. Third copy of the tag regex — platform-objects, service-messaging and now spec each spell it; the parity pin covers only the first two. Spec cannot import platform-objects, so a literal copy is the right direction; a parity pin naming the spec copy would close the loop. Non-blocking.

② Semver level

.changeset/25822-locale-tag-shape.md: '@objectstack/spec': minor, Clause-②: no (narrowing) and Metadata-protocol: narrowed on both the changeset and the PR body, BREAKING banner, FROM → TO mapping with the one-line fix, exactly one adr-0087: registered marker naming both ids. A value refused is narrowed, and the level follows the kind (at least minor), so minor is right; the title's feat(spec)! matches. Check Changeset and Lint & Repo Gates are green on the head. Matches.

③ Boundary flags

Implemented-by: claude/project-thread-dmzapg
Reviewed-by: session_01RyMY3prh9GRva1Tt37Di3i

VERDICT: PASS

…d; registry walks no longer call LocaleSchema an open string

Claude-Session: https://claude.ai/code/session_01Do245n6wxzetD6poZNSdeM
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2f0b44d58376c8b5377f865e09570a2dc744a844
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts, packages/spec/src/system/translation.zod.ts + packages/spec/src/system/translation.test.ts, packages/spec/src/shared/retired-key-removal-version.pin.test.ts, .changeset/25822-locale-tag-shape.md, content/docs/protocol/kernel/i18n-standard.mdx, content/docs/ui/translations.mdx, packages/spec/src/conversions/types.ts, packages/spec/src/conversions/walk.ts, packages/spec/src/conversions/stored.ts, packages/spec/src/migrations/types.ts, packages/spec/src/migrations/migrations.test.ts, packages/spec/src/shared/value-domain.zod.ts, packages/spec/src/data/seed.zod.ts, packages/spec/src/data/seed-loader.zod.ts, packages/platform-objects/src/identity/sys-user.object.ts, packages/core/src/fallbacks/authored-translation-sync.ts, packages/metadata/src/loaders/database-loader.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol-save-door-refusals.ts, packages/cli/src/commands/migrate/meta.ts

Narrowing face: the PR body and the changeset both declare Clause-②: no (narrowing), so every non-prose path of the diff is in scope and nothing is a remainder for the lane seat. The prior record on 3e367787 (comment 6411753197) was read as an input; this record judges the whole PR on the new head, whose delta is the registry.ts comment rewording at the two bundle walks plus the two prose pages.

① Derived judgments

  1. Accept set of LocaleSchema — was any string; is now the regex ^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$, byte-identical to SYS_USER_LOCALE_TAG_PATTERN (sys-user.object.ts:33), so a translation can be stored only under a tag a session's own locale column can name. Refused from now on: an underscore separator, surrounding or inner whitespace, the empty string, a language name with spaces or punctuation, a one-letter first subtag (private-use and grandfathered tags, exactly as the user column already refuses them), any subtag over 8 characters. Case is not judged; resolveLocale folds it, so zh-cn keeps working. Syntax only, by decision recorded at value-domain.zod.ts:82. Right.

  2. Reach of the narrowing — re-measured on origin/main: every consumer of LocaleSchema is TranslationItemSchema.locale (translation.zod.ts:2134), the TranslationBundleSchema and PlatformTranslationBundleSchema map keys (:1989, :1999), TranslationConfigSchema.defaultLocale / supportedLocales / fallbackLocale (:2057-2061), Seed.locale[] (seed.zod.ts:213) and SeedLoaderConfig.locale (seed-loader.zod.ts:285). The PR body names all six with a scope verdict each. PackageLocaleSchema (marketplace) is a separate schema and rightly untouched. The three readers of the old open-string assumption (the two registry.ts walks, lint-liveness-properties.ts, the ui clientValidation comment) judge bundle shape structurally and need no code change. Right. The new head rewords the two registry.ts comments to say the schema judges a tag's shape, an open-ended set the keys cannot be enumerated from; that is now an accurate statement of why the walks stay structural. Right.

  3. Error envelope — one invalid_format issue at path locale on the item door (the card's reproduction), one invalid_key at the offending key on both bundle doors, one issue at supportedLocales.N on the config; the message names the shape, the fix (- not _) and the release, with no tracker number in any runtime string (the conversion's TODO text included). The metadata save door maps schema issues to 422 INVALID_METADATA with issues[] through protocol-save-door-refusals.ts:278-300, pre-existing and untouched, so the card's expected envelope holds at the save door, at defineTranslation / defineTranslationBundle and on the bundle map key, as triage 6399808976 ordered. Pinned at the schema layer in translation.test.ts (ten refused values, four doors, two define throws). Right.

  4. Data at rest — D2 translation-locale-tag-canonicalized, toMajor: 18, order 76: on origin/main at 56a234653 the highest MAJOR_18 order is 75 and the one registry.ts commit since this PR's base (f0b33ee9d) is comment-only, so 76 is free and no collision waits in the rebase. retiredFromLoadPath: true with retiredAfter: '18.1.0', the package's current stable label, which is what conversions/types.ts requires under a stable label (one sibling at the same value). It rewrites only the two spellings with exactly one tag they can have meant (_ as separator, surrounding whitespace), and only when the result passes LocaleSchema; everything else is left as stored and sent through context.reportTodo. A bundle key rename goes through renameKey (walk.ts:675), which merges an identical twin and returns null on differing strings, turned into a TODO rather than a silent winner. The letters-only skip keeps a legacy item without locale (group keys such as globalActions) from being misread as a bundle. Reach: translations[] item locale and bundle keys, i18n.*, data[].locale[], through the generic mapCollection (walk.ts:484). STORED_ONLY_COLLECTIONS (stored.ts:58) maps translation to translations, so a stored row reaches this conversion. All three rehydration seams (database-loader.ts:884, authored-translation-sync.ts:172, protocol.ts:6449) replay the chain with retired entries and none re-parses the row through the schema afterwards; authored-translation-sync.ts reads data.locale after the replay, so a rewritten zh_CN row starts resolving for zh-CN sessions, and a residual non-tag row keeps loading as stored, is listed by os migrate meta --stored, and is refused at its next save, which is what the D3 entry states. Deployed rows cannot be measured from the repository; conversion plus TODO listing is the coverage, and the non-test source re-measure (zero underscore, padded or empty literals feeding the schema in packages/ and examples/) holds on origin/main. Fixture: 7 notices, one per rewritten value, covering every reach. Right.

  5. D3 entry translation-locale-non-tag-refused — matches SemanticMigration (migrations/types.ts:99), discovered by directory read (migrations.test.ts:39-51, so no index to register in), conversionIds joins the D2 id, surface carries no backticks (rendered in a code span), replacement does, matching sibling practice. The JOIN row in retired-key-removal-version.pin.test.ts is owed for an entry retired after the newest stable and is present, refusing at path locale. Right.

  6. Public surface — no export added, removed or renamed (LOCALE_TAG_SHAPE and LOCALE_TAG_REFUSAL are module-private); the Locale type stays string; .describe() is unchanged; the committed json-schema.manifest/ records no pattern keyword, so no generated artifact moves, and the head's TypeScript Type Check job (api-surface, docs, authorable-surface, migration-registry gates) is success. The new import of LocaleSchema into conversions/registry.ts follows the precedent of the other schema-module imports there and translation.zod.ts imports nothing from conversions/, so no cycle. Right.

  7. Prose pages on the new head — i18n-standard.mdx and translations.mdx now state the tag shape, the refusal and the conversion's reach exactly as the schema and the conversion implement them; Build Docs and Check Documentation Links are success. The changeset's prescription os migrate meta --from 17 --write names a real flag (cli/src/commands/migrate/meta.ts:1054). Right.

  8. Boot-time reach noted in the acceptance notes — a non-tag i18n.defaultLocale that reaches AppPlugin.resolveSeedLocale() outside defineStack and outside the artifact window now fails SeedLoaderRequestSchema.parse loudly instead of filtering seeds on a locale no dataset can name. That is the contract-first direction (fail loud, no consumer accommodation). Right.

  9. Third copy of the tag regex — platform-objects, service-messaging and now spec each spell it; the parity pin covers only the first two. Spec cannot import platform-objects, so a literal copy is the right direction; a parity pin naming the spec copy would close the loop. Non-blocking.

② Semver level

.changeset/25822-locale-tag-shape.md: '@objectstack/spec': minor; Clause-②: no (narrowing) and Metadata-protocol: narrowed on both the changeset and the PR body; BREAKING banner; FROM → TO mapping with the one-line fix; exactly one adr-0087: registered marker naming both ids. A value refused is narrowed, and the level follows the kind (at least minor on the package the diff moves), so minor is right; the title's feat(spec)! matches. Only @objectstack/spec publishes a change: the two prose pages publish nothing. Check Changeset and Lint & Repo Gates are success on the head. Matches.

③ Boundary flags

Implemented-by: claude/project-thread-dmzapg
Reviewed-by: session_01RyMY3prh9GRva1Tt37Di3i

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:spec seat 2 · 2026-10-26T09:11Z · head 2f0b44d58376c8b5377f865e09570a2dc744a844

  1. Shape. Draft, base main, ! title, closes spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822. 8 files, +369/−5. check-governed-merges --pr 25983: NOT governed (0 of 8 paths hit the register).
  2. Premise and stop conditions. The premise held: LocaleSchema accepted any string while sys_user.locale already judged the BCP-47 shape. The new pattern is byte-identical to SYS_USER_LOCALE_TAG_PATTERN. All six consumers narrow through the one edit; PackageLocaleSchema is untouched. The landing point is as ordered.
  3. Gates (check-runs pinned to this head). 48 runs: 41 success, 7 skipped, 0 failed. check-expected-skips --pr 25983: OK.
  4. Contract review. PASS at CONTRACT_REVIEW_TIER on this head, comment 6412325845. The earlier PASS (6411753197) was on the previous head; this head adds the registry.ts comment fix (the comments no longer call the set an open string) and two prose pages. The whole head was re-judged.
  5. Narrowing. Clause-②: no (narrowing) and Metadata-protocol: narrowed on the PR body and the changeset; @objectstack/spec minor with the BREAKING banner, FROM → TO and the fix. Data at rest is covered by D2 translation-locale-tag-canonicalized (order 76, free on origin/main) and its D3 entry; residual non-tags keep loading and are TODO-listed. The dogfood battery ran full on 3e367787; its one red was main's authz-probe-blind-spot census, anchored on dogfood: main's Dogfood Regression Gate (3/3) is red since #25956 — the authz blind-spot census still pins handleI18nRequest at 3 in packages/runtime/src/domains/i18n.ts; #25956 made it 4 #25988, which is now closed. The head delta since then is comment text and two prose pages that no dogfood test reads.
  6. Accepted, not fixed here. The tag regex now has a third literal copy (platform-objects, service-messaging, spec); the parity pin covers only the first two. Non-blocking.
  7. Serial constraint. spec: retire tombstones, conversions and migration entries older than one protocol major; a strict unknown-key refusal with an upgrade-guide link takes over (maintainer ruling D2 = A) #25767 lands after this PR.
  8. Landing. Ready plus auto-merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string()

2 participants