Skip to content

fix(objectql): key i18n bundles by (name, locale) so no member is overwritten (#7730) - #7775

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7730-registry-locale-key
Aug 11, 2026
Merged

fix(objectql): key i18n bundles by (name, locale) so no member is overwritten (#7730)#7775
os-zhuang merged 1 commit into
mainfrom
claude/issue-7730-registry-locale-key

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #7730

The contract this restores — verified, quoted

packages/spec/src/system/email-template.zod.ts L82-86:

IETF BCP-47 locale tag. Multiple rows with the same name but different locale form an i18n bundle; the service picks the best match for the recipient's locale, falling back to en-US.

and the schema header, L20-22: "a single named template is resolved by (name, locale)". So the declared identity of an email template is a pair, and the spec does state the i18n-bundle semantics the card asserts — this is a restore-invariant, not a contract question.

Premise — still valid on origin/main @ 8669e5d

registry.ts computed the item storage key with no locale component, so the second row of a bundle collided with the first and went through the [Registry] Overwriting … path. Confirmed by reverse verification rather than by reading: neutralizing only the key computation (leaving every other change in place) turns 4 of the 18 new tests red, with expected [ … ] to have a length of 2 but got 1 — the reported symptom exactly.

What changed

packages/objectql/src/registry.ts, one file:

  • The key carries the declared identity. A metadata type may declare a discriminator in ITEM_KEY_DISCRIMINATORS; its items are stored under <packageId>:<name>@<discriminator> instead of <packageId>:<name>. An item declaring no discriminator is keyed as the canonical member, so { name } and { name, locale: 'en-US' } stay one template and re-registration stays idempotent.
  • Reads make the round trip whole. Storing both rows is half a fix if a lookup then returns an arbitrary one, so a bare-name read of a bundled type resolves through the same precedence tiers as before — ADR-0005 bare-key overlay, then ADR-0048 prefer-local, then first composite — over bundle groups instead of single keys, picking the canonical (en-US) member inside the winning tier. That is the locale sendTemplate already falls back to, so the answer is decided by the spec rather than by Map iteration order. getArtifactItem keeps serving the packaged member over an overlay.
  • Withdrawal by name takes the whole bundle (unregisterItem, removeOverlayEntry, removeRuntimeShadow), matching the consumer side: deactivateDeclaredEmailTemplate sweeps sys_email_template by name across locales precisely because a delete event carries no locale. Leaving a member registered would make a withdrawn template re-seed. The group is still chosen exactly as before (bare group if present, else the first composite group), so a second package's same-named bundle is untouched — pinned.

Why a per-type declaration and not a universal extra key segment, and not duck-typing item.locale: the key computation is generic to every registered metadata type. Measured on origin/main, email_template is the only registered type whose schema declares a top-level locale that is part of its identity ( locale: across packages/spec/src/**/*.zod.ts otherwise hits SCIM users, execution context, API discovery/protocol payloads and the translation payloads — none of them a registered metadata type). Reading whatever item.locale happened to be present would silently re-key any type that later grows a locale-ish field; the table makes that a deliberate act. A test pins the table to exactly one entry today, and another pins its canonical locale against EmailTemplateDefinitionSchema's own parsed default so the literal cannot drift from the spec.

Which item types this affects, and which it provably does not

Affected: email_template only. Provably not affected: every other registered type — the discriminator lookup is ITEM_KEY_DISCRIMINATORS[type], undefined for all of them, and each touched method early-returns into the pre-existing code path unchanged. Pinned from the other side too: two page items sharing a name and differing only in a locale property still collapse to one, last-write-wins.

Cross-seat surfaces

None. packages/spec is not touched — the spec was read and quoted, not changed. No packages/spec change turned out to be needed; if a reviewer wants the canonical locale exported from the schema instead of pinned by test, that is a domain:spec declaration on #6017 and a follow-up.

Consumer sweep

Every consumer of the item storage-key shape is inside registry.tsgit grep for endsWith(':' / endsWith(\:${…}`)and for${packageId}:${…}composite builders finds no other reader anywhere in the repo (the one non-registry hit is a spec build script matching React block names, unrelated). Thesys_email_templatematerializer readsregistry.listItems('email_template')(values, not keys) and upserts on(name, locale)` — already locale-aware, which is why the registry was the whole defect on that path.

One consumer is deliberately out of scope and filed: #7774MetadataProtocol's unscoped list merge dedups by metaItemKey(packageId, name) with no locale, so GET /meta/email_template can collapse the bundle again when a metadata service is installed. It is not on this issue's reported path (the materializer bypasses it) and its fix needs the discriminator table to live in a package metadata-protocol depends on, which @objectstack/objectql is not.

Verification

  • Build closure first: pnpm --workspace-concurrency=2 --filter '...@objectstack/objectql...' build — green, before any typecheck.
  • Consumer sweep, PREFIX direction (downstream consumers, ...@objectstack/objectql): Scope: 40 of 78 workspace projects, typecheck green with no error TS. The first attempt was a false red — Cannot find module '@objectstack/objectql/core' and '@objectstack/plugin-hono-server' — from unbuilt dist, which is why the closure build above came first and was re-run over the union of consumers and their dependencies.
  • Suites: @objectstack/objectql 184 files / 3255 tests passed; @objectstack/metadata-protocol 72 / 1066 passed; @objectstack/metadata 31 / 603 passed; @objectstack/plugin-email 20 / 302 passed.
  • New tests: packages/objectql/src/registry-i18n-bundle-key.test.ts, 18 cases, passing.
  • Gates: pnpm check:durability-log-level OK · pnpm check:engine-double-contract OK (150 pinned, 133 debt, 2 exempt) · node scripts/check-engine-split-ratio.mjs OK (ratio 100.0%) · node scripts/check-nul-bytes.mjs OK · eslint on both changed files clean.

Reverse verification — two directions, both predicted before running

  1. Neutralize the key computation only (withDisc → identity, everything else intact): predicted red on the multi-member pins, green elsewhere. Result: 4 failed / 14 passedkeeps both locales of one name, is what the sys_email_template materializer reads back, the cross-package unregister pin, and the disabled-package pin. The other 14 stay green because with one key there is only one row, so the precedence tiers are trivially satisfied; they are regression guards for the new read path, not discriminators of this bug, and item 2 is what shows they are not vacuous.
  2. Neutralize the read side only (bundle resolution removed from getItem, key change intact): predicted red on every bare-name read of a bundled type. Result: 7 failed / 11 passed, all expected undefined to be … — which is the "moved the defect" failure mode in the flesh: the rows exist and nothing can look them up.

No refusal/error case is introduced or rewritten by this change, so there is no ADR-0112 envelope assertion to make; registerItem does not refuse, deliberately (#3903 — registration proceeds on spec-conformance failure).

Against the same-day churn

registry.ts was modified today by 8f1851e (PR #7700, #7557 package-disable propagation), which touched listItems — a reader of exactly the collections re-keyed here. Worked from the post-merge code and pinned that gate specifically: a bundle whose owning package is disabled disappears from listItems in full, and both members come back on enable.


Generated by Claude Code

…rwritten (#7730)

`EmailTemplateDefinitionSchema` declares that multiple rows with the same
`name` and different `locale` form an i18n bundle, and that a template is
resolved by `(name, locale)`. `registerItem` keyed every item by name alone,
so the second locale collided with the first and replaced it through the
`[Registry] Overwriting email_template: ...` path — a stack authoring en-US
and zh-CN copies materialized one row into `sys_email_template`.

The storage key now carries the identity the spec declares: a metadata type
may declare a discriminator (`ITEM_KEY_DISCRIMINATORS`), and its items are
stored under `<packageId>:<name>@<discriminator>`. `email_template`/`locale`
is the only entry; every other type keeps name-only identity, byte-identical
behaviour, and a pin test asserts it.

Reads make the round trip whole rather than moving the defect: a bare-name
lookup of a bundled type resolves through the unchanged precedence tiers
(ADR-0005 overlay, ADR-0048 prefer-local, first composite) and picks the
canonical `en-US` member inside the winning tier, and withdrawal by name
takes the whole bundle — matching the consumer side, where
`deactivateDeclaredEmailTemplate` sweeps by name across locales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3fwc4CLCHphhX4cTcN1KQ
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 2:29pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx (via packages/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 31519403681 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/3) — 失败步骤: Run this shard's tests

    �[41m�[1m FAIL �[22m�[49m src/security/operation-private-keys.pin.test.ts�[2m > �[22mthe `__` operation-private-key convention has one owner (#7284)�[2m > �[22mis declared in exactly one file, and tha
    

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 68 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Copy link
Copy Markdown
Contributor Author

Queue kick triaged — not this PR's fault, ⛔ not re-queued yet (engine-core seat #6019, session_01PzrsfP5pa6xAjXJX7Fhsst).

Removed from the queue at 18:03:41Z, CI_FAILURE, from build 31519403681.

Full signature (pulled from the 1.3 MB job log, ⛔ not the triage bot's truncated extract, which quotes only the test name and reads like an assertion failure):

FAIL src/security/operation-private-keys.pin.test.ts > the `__` operation-private-key
     convention has one owner (#7284) > is declared in exactly one file, and that file is the shared home
Error: Test timed out in 5000ms.
 ❯ src/security/operation-private-keys.pin.test.ts:73:3
Test Files  1 failed | 31 passed (32)   |   Tests  1 failed | 763 passed (764)

Initial reading — a timeout, not an offender. The assertion never ran; no offender list was produced. The test synchronously walks and regexes every .ts/.tsx under packages/ (3,633 files today) under vitest's default 5s timeout. Its two siblings in the same file passed at 0ms and 102ms.

Why this PR is not the cause:

Action taken: the measured counter-evidence is recorded on #7706 (the existing card claiming the queue is blocked by a fourth declaration — that premise does not match this build, and a dev dispatched against it would find nothing).

Why not re-queued right now: this is this PR's first kick, so one re-queue is sanctioned — but the triage checklist's own item 3 says to wait for the batch ahead to land or fail out before re-queuing, and blind re-queues rebuild the entire downstream queue. If the timeout is load-driven, re-queueing into a busy queue reproduces it and burns everyone. Re-queue is scheduled for the next patrol once the queue has drained; membership will be verified by the added_to_merge_queue timeline event, ⛔ not by branch presence. A second kick on this PR hands it to the queue steward (#5810) rather than being retried again here.

The review verdict is unchanged — this PR was and remains ACCEPTed; nothing about its content is in question.


Generated by Claude Code

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 size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

email-template-render (a): declared i18n bundle collides — Registry keys email_template without locale, only the last locale survives

2 participants