Skip to content

fix(metadata-protocol): key the /meta list merge on the i18n bundle discriminator (#7774) - #7931

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-7774-metaitemkey-locale
Aug 12, 2026
Merged

fix(metadata-protocol): key the /meta list merge on the i18n bundle discriminator (#7774)#7931
huangyiirene merged 3 commits into
mainfrom
claude/issue-7774-metaitemkey-locale

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7774

What

GET /meta/email_template served one locale of a declared i18n bundle. #7730 taught the SchemaRegistry that an email_template's identity is (name, locale), so listItems('email_template') returns every member; getMetaItems then merges that listing with two higher layers, and both merges keyed by (package, name) with no discriminator.

Both now key on the pair, and both are byte-identical for a type that declares no discriminator — which is every type except email_template today.

The card's premise failed — premise_still_valid: false

The issue said the sys_metadata overlay merge "needs no change: overlay rows are unique on type+name+org and carry no locale." The claim about the rows is true; the conclusion about the merge is not.

ProbeResult
sys_metadata overlay uniquenessidx_sys_metadata_overlay_active = (type, name, organization_id, package_id); no locale column anywhere in the table — the premise, confirmed
What mergePackageAwareOverlay bucketspush(item.name, …) over baseItems — the SchemaRegistry listing, which since #7730 is the whole bundle
Emissionone row per (bucket, package)a single overlay row for the type dropped a locale, and the survivor was the overlay body regardless of which member it customizes

The rows were never the problem; the base of that merge is. Reverting only the overlay half of this fix turns 3 of the 13 new cases red — see the table below.

The four keying sites, and what changed at each

Anchored by function name (protocol.ts line anchors drift within hours — the issue's :4363/:4377 were still accurate at dispatch time, but the file moved again mid-task).

Site (in getMetaItems)BeforeAfter
MetadataService merge — the itemMap loopsmetaItemKey(pkg, name); second member's set overwrote the firstmetaItemKey(pkg, name, itemDiscriminator(type, entry))
mergePackageAwareOverlay — active overlaysbucket = namebucket = slot = name (+ NUL + discriminator when declared)
mergePackageAwareOverlay — draft previewbucket = namesame slot
env-wide ⊕ org row dedupmetaItemKey(r.package_id, r.name)+ storedRowDiscriminator(type, r), read off the row's serialized body

The last one is the widening the failed premise forces. Within one org it changes nothing — the store's unique index cannot hold two rows differing only by body locale. Across the env-wide and org tiers it can, and keying them together made an org's zh-CN row silently displace an env-wide en-US one. Org-over-env precedence is unchanged where it was ever meaningful (a case pins that an org row still overrides the env-wide row of the same member). Reading the discriminator off the raw stored body rather than the converted one is safe here and checked: no ADR-0087 conversion entry touches email_template.

Where the table lives — and what deliberately did not move

ITEM_KEY_DISCRIMINATORS moves from @objectstack/objectql's registry.ts to @objectstack/metadata-core. @objectstack/objectql depends on @objectstack/metadata-protocol, so the protocol package cannot import the registry's copy without closing a cycle turbo rejects; metadata-core is the package both already depend on and depends on neither — the criterion that sank the engine write-verb dispatch predicates (#5619), the audit-field governance table (#4513) and the injected-system-column definitions (#6562) there. Measured: metadata-core depends on only @objectstack/spec + zod.

No public surface changes.registry.ts re-exports ITEM_KEY_DISCRIMINATORS under its original name from its original module, so registry-i18n-bundle-key.test.ts's import { SchemaRegistry, ITEM_KEY_DISCRIMINATORS } from './registry' keeps working — its Object.keys(...)).toEqual(['email_template']) pin included. metadata-core gains readDiscriminatorValue / itemDiscriminator as additive exports.

Not moved: the registry's storage-key formatBUNDLE_KEY_SEPARATOR, withDiscriminator, bundleBaseKey, collectBundle. Those encode a discriminator into the registry's own composite packageId:name Map keys and parse it back; the protocol layer builds its own NUL-separated merge key and never parses a registry key, so it needs the identity question answered, not the encoding. Moving the encoding would have published a registry-internal key format as a cross-package contract. This is a deliberate half-move, and the TSDoc on both sides says so.

Tests — the first coverage this path has ever had

git grep metaItemKey across packages/ returned exactly one file before this PR: protocol.ts itself. New: packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts, 13 cases.

Both merge blocks are conditional — the MetadataService block runs only with a metadata service installed and answering non-empty for the type; the overlay block only with at least one matching sys_metadata row. A harness that omits either passes against the bug, so every case installs the precondition it targets, and one case (serves the bundle when NEITHER merge block runs) exists precisely to show the shape of harness that made the defect invisible.

Reverse verification

Committed the fix first (dd64b71), then re-introduced the undiscriminated key on top of it. Direction predicted before running: bundle cases red, undiscriminated guards green.

Caserevert bothrevert metaItemKey onlyrevert overlay slot only
precondition: one discriminated type today
precondition: neither merge block runs
service merge: keeps both locales
service merge: service contributes a new locale
overlay: keeps every member with ONE overlay row
overlay: sibling in another package survives
overlay: env-wide + org rows of different locales
overlay: org still overrides env for the SAME locale
overlay: no-locale member is the canonical member
draft preview: sibling survives
ADR-0048: two packages ship page/home
ADR-0048: overlay collapses onto its own package slot
undiscriminated: same-name page is ONE row
5 red / 8 green3 red / 10 green3 red / 10 green

Sample red, revert both:

FAIL the MetadataService merge (the path the card names) > keeps both locales
AssertionError: expected [ 'auth.welcome@zh-CN', …(1) ] to deeply equal [ 'auth.welcome@en-US', …(2) ]
- "auth.welcome@en-US",
"auth.welcome@zh-CN",
"billing.invoice@en-US",

Three cases stay green under the full revert, and that is honest rather than a hole: sibling in another package is separated by ADR-0048's package dimension, which the revert does not touch; org overrides env for the SAME locale and no-locale member is canonical assert that the fix did not split things it shouldn't. Every undiscriminated guard staying green under the revert is the evidence that non-bundled keys are byte-identical — a guard that went red would mean this change altered a key it promised not to.

Gates

Run twice. First on dd64b71+21fc024; then origin/main moved 11 commits — including packages/metadata-protocol/src/protocol.ts (#7906's $icontains fix) and a 345-line rewrite of scripts/check-type-check-coverage.mjs (#7921) — so the full set was re-run on the merge commit 3319a62 rather than a scoped subset. Numbers below are the post-merge run.

GateResult
@objectstack/metadata-core test9 files / 129 tests ✅
@objectstack/metadata-protocol test74 files / 1082 tests ✅
@objectstack/objectql test187 files / 3319 tests ✅ — incl. registry-i18n-bundle-key.test.ts and its ITEM_KEY_DISCRIMINATORS by-name pin
@objectstack/runtime test139 files / 2127 tests ✅
@objectstack/cli test111 files / 1191 tests ✅
typecheck — metadata-core, objectql, runtime, cli✅ clean (metadata-protocol has no typecheck script; it is a measured DEBT entry, covered by the ratchet below)
check:type-check-coverage
check:type-check-debt (--re-measure)✅ 36 entries, 1987 raw errors, none above its recorded number. The pre-merge run first went red at metadata-protocol 73 vs frozen 63, caused by this PR's own test-helper typing (Partial<Row> & { metadata: unknown } collapsing to string); repaired in 21fc024 rather than raising the ledger
eslint on changed files✅ clean
check:i18n✅ 9 packages in sync
check:nul-bytes✅ 7251 files
pnpm --filter @objectstack/spec check:generated✅ 13/13 up to date
pnpm build✅ 71/71

Out of scope, recorded not fixed

duplicatePackage's source-row scan dedupes its env-wide ⊕ org rows by (type, name) with the same locale-blind key. Different feature, different endpoint, outside this PR's declared surface — filed unassigned as #7932 rather than bundled here.

#7491 (stale AUTHORING_RULES comment counts) is unaffected in substance and moved in position: its anchor was already at protocol.ts:2698 on origin/main (not the :2632 the card records), and this PR pushes it further down. The comment still reads "26" against 38 rules actually declared in packages/lint/src/authoring-rules.ts. Not touched here.


Generated by Claude Code

…iscriminator (#7774)
`GET /meta/email_template` served one locale of a declared i18n bundle. #7730
taught the SchemaRegistry that the type's identity is `(name, locale)`, so
`listItems` returns every member; `getMetaItems` then merged that listing with
two higher layers, both keyed by `(package, name)` with no discriminator.
- The MetadataService merge (`metaItemKey`) overwrote the first member with the
second. This is the path the issue named; it runs only when a `metadata`
service is installed and answers non-empty for the type.
- The `sys_metadata` overlay merge (`mergePackageAwareOverlay`) was predicted to
need no change because overlay rows are unique on
`type+name+organization_id+package_id` and carry no locale. True of the rows,
and beside the point: the merge's BASE is the registry's bundle, bucketed by
bare name, so one overlay row for the type dropped a locale and the survivor
was the overlay body regardless of which member it customizes.
`metaItemKey` takes an optional third component and `mergePackageAwareOverlay`
buckets per slot; both omit the component for a type that declares no
discriminator, so every other type's key is byte-identical.
The discriminator table moves from `@objectstack/objectql`'s `registry.ts` to
`@objectstack/metadata-core` — objectql depends on metadata-protocol, so the
protocol package could not import it without closing a cycle, and metadata-core
is the package both already depend on. `registry.ts` re-exports it under its
original name from its original module, so no public surface changes. The
registry's storage-key format stays where it is; only the identity question
moved.
First coverage for this path: `protocol.i18n-bundle-list-merge.test.ts`, 13
cases, each installing the precondition the merge block it targets hides behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxLeX1UvWRzjJ1UUpd7dGt
…heck DEBT ledger (#7774)
The new test's `row()` helper took `Partial<Row> & { metadata: unknown }`, so
`metadata` resolved to `string & unknown` = `string` and every call site passing
an object body was a TS2322. `pnpm check:type-check-debt` measured
metadata-protocol at 73 against a frozen 63 and failed — the ledger is a ratchet
and may only shrink (#5278), so this is a repair rather than a raised entry.
`Omit<Partial<Row>, 'metadata'>` restores the intended `unknown`. No behavioural
change: 13/13 still pass and the reverse verification is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxLeX1UvWRzjJ1UUpd7dGt
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 5:24am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/objectql.

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

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-core, @objectstack/metadata-protocol, @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/metadata-protocol, @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/metadata-core, @objectstack/objectql)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)
  • 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)

3 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/v12.mdx(via @objectstack/metadata-core)
  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

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.

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — flipped ready, auto-merge SQUASH armed @ 3319a62

All 25 jobs concluded before arming: 23 success, 2 skipped (Build Docs, Console Pin Gate — neither applies), zero failures, TypeScript Type Check green at 05:37:38Z. I read each job's own conclusion rather than an aggregate, which is not pedantry this shift — the sibling PR #7920 sat at "0 failed" through three checkpoints and the last job to conclude went red.

The falsified premise is the most valuable thing in this PR

The card asserted the sys_metadata overlay merge "needs no change: overlay rows are unique on type+name+org and carry no locale." You returned premise_still_valid: false with the distinction that matters: the claim about the rows is true and the conclusion about the merge does not follow.mergePackageAwareOverlay buckets over baseItems — the registry listing, which since #7730is the whole bundle — so the rows' uniqueness was never what governed it. A single overlay row dropped a locale.

That was mine to get wrong, and I want it on the record as such: I re-measured this card's four preconditions at source before dispatch and still carried the card's overlay sentence forward without testing it, because it reasoned about the rows and the rows were right. The three-column revert table is what makes the correction checkable rather than assertable — revert overlay slot only turns 3 cases red that revert metaItemKey only leaves green, so the two halves are proven independently load-bearing rather than jointly plausible.

The env-wide ⊕ org widening that falls out of it is correctly scoped and correctly argued: within one org the store's unique index makes it a no-op, across tiers it is real, and you pinned org-over-env precedence for the same member so the fix cannot be read as having loosened it.

Structural claim verified independently

I checked the seam rather than taking the report's word:

  • No package.json in the diff at all — six files, none of them a manifest. The "metadata-core is the package both already depend on" claim is not just stated, it is proven by the absence of a new dependency edge.
  • registry.ts re-exports ITEM_KEY_DISCRIMINATORS under its original name from its original module, and the local discriminatorValue helper is replaced by readDiscriminatorValuealiased to the old name — so not one call site moved and registry-i18n-bundle-key.test.ts's Object.keys(...).toEqual(['email_template']) pin still resolves. objectql's 3315 tests passing is the confirmation, not the argument.
  • The half-move is the right half. Keeping BUNDLE_KEY_SEPARATOR / withDiscriminator / bundleBaseKey / collectBundle in the registry keeps a registry-internal @-encoded Map key from being published as a cross-package contract, while the identity question — the only thing the protocol layer needs — is what got shared. Publishing the encoding would have been the easy over-move and it would have been wrong.

You caught the ratchet yourselves

check:type-check-debt went red on your first run at metadata-protocol 73 vs frozen 63, from this PR's own test-helper typing, and you repaired the typing rather than raising the ledger. That is the same gate that took #7920 to a patch round an hour ago, reached independently and without being told — the dispatch brief named it, but the reasoning for why raising is refused is yours. #5278's argument exactly.

Recorded

Nothing else is owed on this one.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /meta/email_template can collapse an i18n bundle back to one row — the list merge dedups by (package, name), no locale

2 participants

@huangyiirene@claude