Skip to content

fix(spec): resolveBundleLocale and pickData answer only a bundle's own locale keys - #30678

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-30619-bundle-own-key
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-30619-bundle-own-key

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

Requested by Sales · project thread

Fixes #30619

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:resolveBundleLocale 的精确快路径与 pickData 的精确读取改为只认翻译包自己的键(own property),constructor / toString / __proto__ 这类原型成员名不再被当成已注册语言。
  • 为什么改:请求语言码(Accept-Language 首个标签或 ?locale=)原样进入解析器,原型成员名会命中任意普通对象翻译包,解析器把它当语言返回;用户可见的一处是 /meta 对象读取时,平台注入的系统字段(如「Created At」)标签丢失。
  • 风险与回滚:两行判断,无签名、导出或 schema 变化;七个外部调用方的返回值实测不变,唯一变化是系统字段标签恢复为英文默认(详见 Callers 表);回滚即还原 i18n-resolver.ts 这两处。
  • 先看:packages/spec/src/system/i18n-resolver.ts 的 resolveBundleLocale 快路径与 pickData 首行;其次 i18n-resolver.test.ts 末尾新增的 describe 块。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/i18n-resolver.test.ts,预期 501 passed,其中包括末尾 own-key 新块的 9 个用例。
  • 需要你做的:无,席位落地。

What changes

packages/spec/src/system/i18n-resolver.ts, two reads, both now Object.prototype.hasOwnProperty.call (the spec package's tsconfig.json targets lib: ES2020, which has no Object.hasOwn; the file already uses this form at its other two own-key reads):

  • resolveBundleLocale, step 1 (the exact fast path): bundle[requested] !== undefined becomes an own-key check plus the same !== undefined test. An own key whose value is undefined still does not answer this step, as before.
  • pickData, the exact read: bundle[locale] is read only when locale is an own key; otherwise the read goes to resolveBundleLocale as an unknown code does.

The rest of resolveBundleLocale was read and needs no change: steps 2 to 5 (case-insensitive, platform table language, base language, variant expansion) run in resolveLocaleAmong over Object.keys(bundle), which lists own keys only, and pickData's last read, bundle[resolved], is keyed by a code that one of those own-key steps returned. No other read in pickData indexes the bundle by a requested code. Reads keyed by metadata names (objects?.[objectName] and the like) are out of scope and untouched.

No signature, export or schema change, and no edit outside packages/spec.

Callers

resolveBundleLocale has seven non-test callers outside packages/spec on origin/main (the assumption holds, line numbers too), plus four inside packages/spec: pickData (the translators' bundle read), builtinSystemFieldLabel, validation-message.ts and operation-message.ts. Traced read-only:

Caller Where requested comes from Validated or normalized first?
packages/objectql/src/engine-validation-messages.ts:204 (negotiatedMessageLocale) context.locale, which is ExecutionContext.locale: the request's requestLocale ?? localization.locale (packages/core/src/security/assemble-execution-context.ts:394). The dispatcher's request leg is preferredLocaleFromHeader(accept-language) (packages/runtime/src/security/resolve-execution-context.ts:271); the REST leg also takes ?locale=. No. The header's first tag (q-weight stripped, trimmed) or the raw ?locale= string arrives as is.
packages/plugins/plugin-approvals/src/reader-locale-labels.ts:128 readerLabelsFor(service, context?.locale) (approval-service.ts:9622, reader-locale-labels-middleware.ts:127): ExecutionContext.locale. No, same source as above.
packages/plugins/plugin-audit/src/activity-summary-message.ts:278 readerSummaryLocale(getI18n(), ctx.context?.locale, …) (:387): ExecutionContext.locale. No, same source.
packages/plugins/plugin-email/src/email-service.ts:1512 input.locale of sendTemplate / renderTemplate, then the stack's declared fallback. The only callers in the tree are plugin-auth's mails, through emailLocaleArg: the stored sys_user.locale (shape-checked by normalizeRecipientLocale, ^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$), else the request's Accept-Language tag only when it is a shipped auth-mail locale (authEmailLocaleFromRequest), else the deployment's configured locale. Yes. No Object.prototype member name passes the tag shape or the shipped-locale list.
packages/runtime/src/sandbox/hook-refusal-catalogue.ts:113 engineCtx.session.locale (:181): the request's execution-context locale. No.
packages/services/service-automation/src/engine.ts:1462 (negotiatedServiceLocale) AutomationContext.locale, the door's ExecutionContext.locale (its own docblock: "whose header leg is the request's tag verbatim"), via translatedFlowTemplate in node-text-rendering.ts:197. No.
packages/services/service-messaging/src/notification-text.ts:353 recipientLocale(): resolveRecipientLocale(sys_user.locale, deployment default); the public render(keys, literal, locale) takes a caller's locale, and no non-test caller in the tree calls it. Yes, normalizeRecipientLocale's tag shape.
packages/spec/src/system/i18n-resolver.ts pickData (inside packages/spec) Every code of the translators' locale chain: the request locale the serving layer threads (extractLocale / metaRequestLocale: the Accept-Language top tag or a raw ?locale=), then the declared fallback chain. No.
packages/spec/src/system/i18n-resolver.ts builtinSystemFieldLabel (inside packages/spec, called from translateObject's field pass) The same locale chain, per platform-injected system field still carrying its English default. No.
packages/spec/src/system/validation-message.ts:399, operation-message.ts:332 (inside packages/spec) The locale option their callers pass, the same ExecutionContext.locale in the engine paths. No.

So five of the seven outside callers, and all four inside ones, pass an unvalidated request value. What it measurably changed before this PR:

  • Every one of the five outside callers ends resolveBundleLocale(offered, requested) ?? requested, so a prototype-name request came back as itself both before and after the fix, and the answer the caller hands t() is the same.
  • The two message catalogs read BUILTIN_*_MESSAGES[matched][messageKey]. No built-in message key is a Function or Object.prototype member name, so they fell back to the default locale's template before the fix too.
  • pickData's inherited answer was never read through: every reader's first hop is a fixed group (objects, apps, settings, …) that none of those members carries.
  • One wrong answer was user-visible, and this PR removes it. builtinSystemFieldLabel returns entry[resolved] directly, and translateObject's field pass assigns any truthy answer to next.label. Before this PR an object read (/meta) under ?locale=constructor or Accept-Language: constructor served the Object constructor as the label of every injected system field still at its English default; JSON serialisation dropped it, so the label went missing. Under __proto__ it served Object.prototype (an object where a string belongs). Now the ladder answers undefined for those codes, the chain moves on, and the English default stands.

Tests

New block at the end of packages/spec/src/system/i18n-resolver.test.ts (bundle locale keys are the bundle's OWN keys …), at the end of the file so #30618's cases cannot conflict with it:

  • for each of constructor, toString and __proto__: resolveBundleLocale({ en: {} }, code) equals resolveBundleLocale({ en: {} }, 'xx') (both undefined), and the same over { en, zh-CN };
  • for each of them, the exported reader resolveViewLabel (which reads through pickData; pickData itself is not exported) answers exactly what it answers for 'xx': the literal label, or the next locale of the fallback chain;
  • a bundle that only inherits zh-CN (Object.create(bundle)) answers zh-CN the way it answers an unknown code, in resolveBundleLocale and through resolveViewLabel;
  • an injected system field (created_at, label Created At) read through the exported translateObject with { locale: 'constructor' } and { locale: '__proto__' } keeps a string label equal to what 'xx' gives (Created At). This pins the served-document path above;
  • an own constructor and __proto__ key (built by JSON.parse) and an own constructor on an Object.create(null) bundle still answer, and resolveViewLabel reads the own constructor data. This shows the fix is an own-property check, not a deny-list.
Readings (head fdba0db, round 1)
  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/i18n-resolver.test.ts: Tests 501 passed (501), lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/spec test: Test Files 817 passed (817), Tests 25783 passed | 1 todo (25784), VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec check:generated: exit 0, ✓ All 14 generated artifacts are up to date, including check:test-typecheck green with the new pin. In round 0 an earlier run caught one new TS2740 in the first version of the test (a constructor assignment typed as Function); the fixture was rewritten, and the ledger was not touched.
  • eslint --no-inline-config on both changed .ts files: exit 0, no output.
  • node scripts/check-changeset-no-major.mjs --base b3d2f4bdb --event EVENT (a pull_request payload carrying this body's two declaration lines): exit 0, ✓ LEVEL AXIS: this PR declares clause-② no and metadata-protocol none … a non-protocol change at patch.

Ablation (via scripts/ablation-replace.mjs, which takes a literal anchor, checks that it hit, and restores; the spec tests import ./i18n-resolver from source, so no dist leg applies):

  • Leg A, the fast path reverted to if (bundle[requested] !== undefined) {. Round 1, on fdba0db: anchor x1 → x0, blob 405d8b76 → d21038ff. Tests 5 failed | 496 passed: the three prototype-name resolveBundleLocale pins, the inherited-key pin, and the new translateObject system-field pin. Restored: blob after restore 405d8b76 equals the HEAD blob, and git diff HEAD is empty. In round 0, on aa1ad0b, the same leg gave 4 failed, before the system-field pin existed.
  • Leg B, pickData's read reverted to const exact = bundle[locale]; (round 0, on aa1ad0b): anchor x1 → x0, blob 405d8b76 → eb268592. Tests 1 failed | 499 passed: the inherited-key pin. Restored the same way, blob equals HEAD. i18n-resolver.ts has not changed since, so its blob is still 405d8b76.

Observed direction for leg B: the constructor/toString/__proto__ reader pins stay green with pickData's read reverted. No exported reader reads a property that Object, Function.prototype.toString or Object.prototype carries (every reader's first hop is a fixed group such as objects, apps or settings), so the function pickData returned was never visible through them. The inherited-key pin is the one that shows the pickData read on its own.

Acceptance notes

  • Reads at merge base b3d2f4bdb. origin/main has moved 6 commits since, none in i18n-resolver.ts, its test or platform-locales.ts, so no merge was taken. If platform locale table: script-subtag codes (zh-Hant-TW, zh-Hans-CN, zh-Hant-HK) are not read aliases, so a two-script bundle answers them with the first registered zh-* key #30618 lands first, main is merged in (no rebase).
  • The ruling's "pickData returns undefined" is pinned through resolveViewLabel, because pickData is not exported and the ruling forbids an export change. See the leg-B note above for what that pin can and cannot see.
  • resolveBundleLocale({ en: {}, fr: undefined }, 'fr') still answers 'fr'. The fast path skips the undefined value as before, but step 1 of resolveLocaleAmong finds fr in Object.keys. This is unchanged by this PR and is not pinned here.
  • Five of the seven outside callers, and all four inside ones, pass an unvalidated request locale (table above). After this PR none of them gets a wrong answer from it. Whether a door should validate the locale before it builds ExecutionContext.locale is a separate question this PR does not take up.
  • Round 1 (contract review 6671371228, should-fix S1): the round-0 census missed the inside caller builtinSystemFieldLabel, and with it the one user-visible wrong answer: a missing system-field label under a prototype-name locale. That answer is fixed by the same fast-path change. Round 1 adds the translateObject pin for it, corrects the census and the impact sentence here, and names the label in the changeset. No source change: i18n-resolver.ts is byte-identical to round 0. origin/main moved no commit on i18n-resolver.ts, its test or platform-locales.ts, so no merge was taken.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa

…n locale keys

A requested code naming an Object.prototype member (constructor,
toString, __proto__) was found on every plain-object bundle:
resolveBundleLocale's exact fast path answered it as a registered locale
and pickData's exact read handed back the inherited function as bundle
data. Both reads now use an own-property check, so the code takes the
same fallback an unknown code takes; an own key of that name still
answers.

Claude-Session: https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa
Co-authored-by: Claude <noreply@anthropic.com>
…constructor write

Claude-Session: https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation protocol:system tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/email-templates.mdx (via resolveBundleLocale (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 63 of 221 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 170 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 788715ef5cb205d3e70052d25a595e13a8aa9d7c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44dd999eddf9bf57c4142f93641b040160c3be33 — the merge of head fdba0dbd7563d1da25a725806ed887270211b89f into base 788715ef5cb205d3e70052d25a595e13a8aa9d7c, 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 44dd999eddf9bf57c4142f93641b040160c3be33 && git checkout 44dd999eddf9bf57c4142f93641b040160c3be33
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 788715ef5cb205d3e70052d25a595e13a8aa9d7c fdba0dbd7563d1da25a725806ed887270211b89f && git checkout -B drift-repro 788715ef5cb205d3e70052d25a595e13a8aa9d7c && git merge --no-ff fdba0dbd7563d1da25a725806ed887270211b89f

node scripts/docs-audit/affected-docs.mjs --json 788715ef5cb205d3e70052d25a595e13a8aa9d7c

⚠️ 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 788715ef5cb205d3e70052d25a595e13a8aa9d7c → 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

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

Contract-review-claim: session_01L6bhgTqqeHSwZYJmApbDCa · head a160083 · 2026-11-05T22:10Z

Full review at CONTRACT_REVIEW_TIER (published schema face: system/i18n-resolver.ts; two own-property reads, Clause-②: no), run by an isolated at-tier subagent of this seat.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a160083beab329a933ae8af9867049e08a52b3e4
Local-runs: none
Scope: packages/spec/src/system/i18n-resolver.ts + packages/spec/src/system/platform-locales.ts, packages/spec/src/system/translation.zod.ts, packages/spec/src/system/validation-message.ts, packages/spec/src/system/operation-message.ts, packages/spec/tsconfig.json, packages/objectql/src/engine-validation-messages.ts, packages/plugins/plugin-approvals/src/reader-locale-labels.ts, packages/plugins/plugin-audit/src/activity-summary-message.ts, packages/plugins/plugin-email/src/email-service.ts, packages/runtime/src/sandbox/hook-refusal-catalogue.ts, packages/services/service-automation/src/engine.ts, packages/services/service-messaging/src/notification-text.ts, packages/services/service-messaging/src/recipient-locale.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/core/src/security/assemble-execution-context.ts, packages/runtime/src/security/resolve-execution-context.ts, packages/rest/src/meta-item-read-gate.ts, packages/core/src/fallbacks/memory-i18n.ts; the remainder of the diff (packages/spec/src/system/i18n-resolver.test.ts, .changeset/30619-bundle-locale-own-key.md) is the lane seat's ACCEPT at TIER_DEFAULT

Inputs read: card #30619 (body, triage 6666526394, claim 6669698230, dev report 6670828385) and its origin, the contract review of PR #30601 (comment 6665489168, S6); PR #30678 body, file list (3 files, +60/−3) and the net diff of merge base b3d2f4bd → a160083b; the check-runs on the head. Read-only: nothing built, run or re-run; no probe. The dispatch order and the dispatching seat's conclusions were not inputs.

Gate verdicts, read at 2026-11-05T22:37Z after every run had completed (the last at 2026-11-05T22:36Z): 64 check-runs on a160083b, 55 success, 9 skipped, 0 failed, 0 pending. Every required context is success — Lint & Repo Gates, TypeScript Type Check, Test Core (8/8 shards and the roll-up), Build Core, Governed Surface Queue Guard, Type Check UI, Test UI (16/16 shards and the roll-up), Build & E2E UI, Live E2E UI — with Lint UI skipped (no ui/ path in the diff) and Check Changeset success; the other skips are the opt-in and docs lanes. These conclusions are the derived-gate verdicts of this record; nothing was re-run. Polled on the three-minute cadence from the first reading, with one early re-read (about ninety seconds after the previous) that is recorded here and changed nothing.

① Derived judgments

  • Public surface unchanged — right. resolveBundleLocale(bundle, requested) keeps its signature (:521-524); pickData is module-private; the diff touches no api-surface/ or export-origins/ shard. Two reads change their answer and nothing else moves.
  • Only an inherited member name stops answering as a bundle key — right, and the set is exactly that. Before this head the fast path bundle[requested] !== undefined and pickData's bundle[locale] answered every defined inherited member of a plain-object bundle: constructor, toString, __proto__, hasOwnProperty, valueOf, isPrototypeOf, propertyIsEnumerable, toLocaleString and the __defineGetter__ family. With the own check first, each of those reads as an unknown code and falls to resolveLocaleAmong over Object.keys(bundle), which lists own enumerable keys only and whose five steps (exact, case-folded, table, base language, variant) cannot match a name that is not a key. An own key spelled that way still answers, as the pins hold. No authored bundle can carry one: LocaleTagKeySchema (translation.zod.ts:99-102) tests LOCALE_TAG_SHAPE (two or three letters, then optional subtags), so constructor and __proto__ are refused at parse; only a hand-built literal or a JSON.parse can, and those keep answering.
  • An own key whose value is undefined answers exactly as before — confirmed, no change. Before: the fast path fails on the value and step 1 of resolveLocaleAmong finds the key in Object.keys and answers requested. After: the own check passes, the value test fails, the same step answers the same code. pickData on such a key gives undefined both before and after (exact is undefined, resolveBundleLocale answers the key, bundle[resolved] is undefined). So the !== undefined half of the fast path never decided the function's answer for an own key; it decided only the inherited-name case, which the own check now decides first.
  • Caller-observable change, measured against the files. The seven outside callers the claim lists are all the non-test callers outside spec at this head (git grep of the head: 7 outside, plus pickData :602, builtinSystemFieldLabel :4495, validation-message.ts:399 and operation-message.ts:332 inside). Five build a Record literal of true from getLocales() and end ?? requested, so a prototype-name request answers itself before (through the false hit) and after (through the ??): no change. email-service.ts:1512 folds a validated tag (below) and now skips one loader.load(template, 'constructor') that returned no row: same outcome. The two spec message catalogs read BUILTIN_*_MESSAGES[matched][messageKey]; no key of either catalog is an own property of Object, of Function.prototype.toString or of Object.prototype (both key lists scanned), so they fell to the fallback locale before and after. The eighth caller is the one that changes, and the body does not list it — ④ S1.

② Semver level

  • @objectstack/spec patch — right. The classifier's level axis (check-changeset-no-major.mjs, the LEVEL AXIS header) follows the Metadata-protocol: kind alone: none takes patch.
  • Metadata-protocol: none — right, judged on the tree. The kind is the change to what a metadata author can write and what it means. No authored key's meaning moves: every parsed bundle is LocaleTagKeySchema-shaped, so none ever registered a prototype name, and every key an author can write answers the same requests as before. What moves is the answer to a REQUEST no author wrote, over a key no author can write. That is a resolver defect removed, not a semantic change to the protocol. The #30601 precedent went semantic because WHICH authored key answered a reader changed; nothing of that kind happens here.
  • Clause-②: no — right. Clause-② asks whether the diff widens or narrows a published accept set or public surface. No signature or export moves; resolveBundleLocale accepts every string it accepted. The hit set over a plain-object bundle loses only names that were never registered keys, which is the defect's removal. (narrowing) would wrongly mark this breaking; no is the declaration the tree earns.

③ Boundary flags

  • pickData pinned through resolveViewLabel, plus an inherited-key pin via Object.create(bundle) (dev flag) — accepted, with one gap. pickData is not exported and the card forbids an export change, so a reader pin is the right shape; the Object.create(zhBundle) pin is the one that goes red on the leg-B ablation, as the dev measured. The gap is not in pickData but in the caller the census missed (④ S1): the served-document path that visibly changed has no pin.
  • Object.prototype.hasOwnProperty.call because lib is ES2020 (dev flag) — right. packages/spec/tsconfig.json sets lib: ES2020 over tsconfig.base.json's ES2020; Object.hasOwn is ES2022 and appears in the spec package only in test files, which the package tsconfig excludes. The form matches the file's two existing own-key reads (:2206, :5993) and is the shape eslint's no-prototype-builtins asks for; the config carries no prefer-object-has-own. Calling Object.prototype's method with .call is also what makes the read hold on a null-prototype bundle and on a bundle that shadows hasOwnProperty itself.
  • No merge of origin/main (dev flag) — right. origin/main is 12 commits past the base at this reading and none touches i18n-resolver.ts, its test or platform-locales.ts (git log of the range on those paths is empty); the PR is mergeable. The new describe sits at the file's end, so platform locale table: script-subtag codes (zh-Hant-TW, zh-Hans-CN, zh-Hant-HK) are not read aliases, so a two-script bundle answers them with the first registered zh-* key #30618's cases, if they land first, merge textually.
  • resolveBundleLocale({ en: {}, fr: undefined }, 'fr') answers 'fr' (dev out-of-scope note) — pre-existing, consistent, and immaterial. It was never inconsistent with the fast path in effect: the !== undefined test has only ever skipped the fast path, and step 1 of the ladder has always answered the own key regardless (① third bullet). After this head that is still the whole story. It matters to no reachable bundle: TranslationDataSchema refuses an undefined value, the five offered literals hold true, labelCatalogs stores {} for a miss, and pickData answers undefined for the key either way. No card. The fast-path comment and the changeset's "does not answer the exact step" are true of the step; a reader who takes it as the function's answer is set straight by the body's acceptance note. Optional wording only.
  • ExecutionContext.locale is unvalidated (dev out-of-scope note) — confirmed and escalated as an observation, not a card. assemble-execution-context.ts:394 takes requestLocale ?? localization?.locale; the dispatcher's leg is preferredLocaleFromHeader (i18n-resolver.ts:4222: the first comma-separated tag, q-weight stripped, no shape test) and the REST face adds ?locale= through meta-item-read-gate.ts's requestLocale. So a raw constructor reaches every translator's ladder. After this head the resolver answers "unknown" for it at every entry, the five pass-through callers hand it to t(), and service-i18n carries no raw [locale] index read; LOCALE_TAG_SHAPE already exists (recipient-locale.ts:115) and the auth mails and notifications gate on it. Whether the two doors should refuse a tag that fails that shape is a door question for its own card if the maintainer wants it; no wrong answer is reachable from the resolver after this head, so I file none.
  • open_questions: none in report 6670828385 — nothing to answer.
  • Deviations judged: the attribution comment on body line 1 is the harness form; the TS2740 fixture rewrite left the ledger untouched (the diff carries no debt file); the ablation legs are the dev's own readings and were not re-run — the check-runs are the gate authority here.
  • Dev report accuracy: the summary's "No user-visible sentence changed before or after; the fix is latent-contract hardening" is refuted by ④ S1 below; the body's same sentence likewise.

④ Design judgment

Judged on the tree at a160083b against the card's scenario: a plain-object bundle asked for constructor, toString or __proto__ through a door that passes a raw request tag; the same over a null-prototype bundle, a class instance and a bundle carrying such a key as its own.

packages/spec/src/system/i18n-resolver.ts — holds.

  • Both reads are immune to every inherited member. The fast path (:528) and pickData's exact read (:600) test Object.prototype.hasOwnProperty.call(bundle, code) before indexing. On a plain object, a JSON.parse result, a Zod-parsed bundle (a z.record parse yields a plain object, and foldLocaleKeys rebuilds one when it folds) and translationBundleOf's literal (meta-item-read-gate.ts:2894-2899), an inherited name is not own and falls to the ladder, where Object.keys cannot list it. On a null-prototype bundle the call form works without the object inheriting the method; the pin bare holds it (constructor own → answers, toString → undefined). On a class instance, own fields answer and prototype getters do not — the documented meaning of "own keys". A Map-like bundle answers nothing before and after save the false constructor hit it loses, and no caller passes one: the seven outside callers build literals or Object.fromEntries, and core's Map-backed store (memory-i18n.ts:89) goes through resolveLocaleAmong with a key list, never through resolveBundleLocale.
  • No other read on either path indexes the bundle with a requested or derived code without an own check. resolveBundleLocale's only other read is Object.keys(bundle); resolveLocaleAmong reads the available array by includes and find, and its table step calls platformLocaleTagOf, which is a Map.get over a case-folded index (platform-locales.ts:87-117): the alias map is never indexed as an object with the raw code. pickData's last read bundle[resolved] is keyed by a code the own-key ladder returned. Off the two paths but in this file: labelCatalogs (:467-469) writes unchanged[code] = … with each chain code; a __proto__ code there sets the local object's prototype instead of a key, after which pickData(unchanged, '__proto__') reads it as unknown — the same answer as for the bundle itself, on an object that outlives nothing. SYSTEM_FIELD_LABELS[fieldName] (:4493) indexes by a metadata field name, the class the card and the body put out of scope. Neither is a finding.
  • __proto__ as an own key behaves as the pins claim. JSON.parse creates it as an own data property; Object.keys lists it; the own check is true and the value {} is defined, so resolveBundleLocale answers '__proto__' and pickData reads its data. The inverse holds too: the callers that build bundles by assignment (offered[code] = true, unchanged[code] = …) cannot create such a key, because assignment to __proto__ sets the prototype — so a getLocales() list carrying __proto__ registers nothing, which is harmless and pre-existing.
  • Docblocks and comments are true. resolveBundleLocale's docblock is untouched and still accurate ("run over the bundle's keys"; the five-step order); resolveLocaleAmong's is untouched. The two new comments state exactly what the code does.
  • Break attempts that did not break it: a bundle that shadows hasOwnProperty with its own key; a bundle with an own constructor beside inherited toString; Object.create(bundle) layering (every key inherited → Object.keys empty → undefined, as pinned); mixed case CONSTRUCTOR (no own key, no folded key, no table tag, no base, no variant → undefined); __proto__ with fallbackChain (falls to the next chain code, as pinned through resolveViewLabel).

Must-fix: none.

Should-fix:

  • S1 (this PR, body and one pin): the PR body's caller table and census omit builtinSystemFieldLabel (i18n-resolver.ts:4486-4498, the call at :4495), the one caller whose answer was user-visible. It returns entry[resolved] straight, and translateObject's translateField (:4960-4963) assigns a truthy answer to next.label. Before this head a /meta object read under ?locale=constructor or Accept-Language: constructor therefore served the Object constructor as the label of every injected system field still carrying its English default (dropped at JSON serialisation, so the label went missing), and under __proto__ served Object.prototype as the label (an object where a string is served). This head fixes it: the ladder answers undefined, the chain moves on, the default stands. So the body's "no user-visible sentence changed" and "not yet a wrong sentence for any caller" (and the dev report's "latent-contract hardening") are wrong in that one cell: correct the census (seven outside, four inside counting pickData) and the impact sentence, and add one pin through the exported translateObject — a system field at its injected default, { locale: 'constructor' } and { locale: '__proto__' }, label unchanged — the pin that goes red on the leg-A ablation for a served document. The tree is right; the account of it is not.

Implemented-by: claude/issue-30619-bundle-own-key
Reviewed-by: session_01L6bhgTqqeHSwZYJmApbDCa

VERDICT: PASS


Generated by Claude Code

…through translateObject

builtinSystemFieldLabel returned entry[resolved] straight, so before the
own-key fast path a constructor or __proto__ locale served an inherited
member as an injected system field's label. The pin reads created_at
through translateObject under both names and expects what 'xx' answers.
The changeset now names that user-visible answer.

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

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fdba0dbd7563d1da25a725806ed887270211b89f
Local-runs: none
Scope: packages/spec/src/system/i18n-resolver.ts + packages/spec/src/system/i18n-resolver.test.ts, .changeset/30619-bundle-locale-own-key.md (the delta's two files, read as this round's claim), and the cited code of record 6671371228 (translateObject, builtinSystemFieldLabel, labelCatalogs, lookupObjectFieldAttr, valueOverridesPackagedBase in the hits file); the remainder of the diff outside the hits file is the lane seat's ACCEPT at TIER_DEFAULT

Delta review: the head moved from a160083b to fdba0dbd by one commit (test(spec): pin the system-field label under a prototype-name locale through translateObject). Record 6671371228 (PASS, should-fix S1) covers everything this commit does not touch and is carried; this record judges the delta and re-reads what S1 asked for. Inputs read: the net diff a160083b → fdba0dbd (2 files, +11/−1) and the net diff against merge base b3d2f4bd (3 files, +70/−3); the dev's r1 report 6672135607 on #30619; the PR body as refreshed by the seat; the check-runs on the new head. Read-only: nothing built, run or re-run; the dev's ablation numbers are the dev's readings. The dispatch order and the dispatching seat's conclusions were not inputs.

Gate verdicts, read at 2026-11-05T23:28Z in one reading taken after every run had completed (the last at 2026-11-05T23:26Z): 68 check-runs on fdba0dbd, 57 success, 11 skipped, 0 failed, 0 pending. Every required context is success — Lint & Repo Gates, TypeScript Type Check, Test Core (8/8 shards and the roll-up), Build Core, Governed Surface Queue Guard, Type Check UI, Test UI (16/16 shards and the roll-up), Build & E2E UI, Live E2E UI — with Lint UI skipped (no ui/ path in the diff) and Check Changeset success (two runs, both green); the other skips are the opt-in, docs and label lanes. These conclusions are the derived-gate verdicts of this record; nothing was re-run.

① Derived judgments

  • i18n-resolver.ts is unchanged — confirmed on the tree. git ls-tree at both heads gives blob 405d8b76e0ad64db79ff07db6783d2cd77199f04 for packages/spec/src/system/i18n-resolver.ts; the delta diff has no hunk in it. Every ① judgment of record 6671371228 (no signature, export or schema move; only inherited member names stop answering; an own undefined-valued key answers as before) stands unchanged on this head.
  • The new pin reaches builtinSystemFieldLabel — yes, traced. translateObject(doc, undefined, { locale }) does not return early on a missing bundle (the function's own comment says the built-in system-field labels still apply; if (!objectName) return doc is the only guard, and doc.name is 'contract'). labelCatalogs(undefined, opts) hands back { unchanged: undefined, changed: undefined }. In translateField for created_at: no packagedBase, so valueOverridesPackagedBase(undefined, 'label', 'Created At') is false (its first line) and the unchanged-catalog branch runs; lookupObjectFieldAttr(undefined, …) is undefined (its first line); ?? then calls builtinSystemFieldLabel('created_at', 'Created At', { locale }). There SYSTEM_FIELD_LABELS.created_at.en === 'Created At', so the guard passes, localeChain is [locale] alone (no fallbackChain, no defaultLocale), and resolveBundleLocale(entry, 'constructor') runs. On this head the own check fails, the ladder over Object.keys(entry) finds nothing, undefined comes back, translatedLabel is undefined, and next.label stays 'Created At' — equal to the 'xx' answer and a string, as the three expectations ask.
  • It goes red with the round-0 fast path reverted — yes, by the same trace. With bundle[requested] !== undefined alone, resolveBundleLocale(entry, 'constructor') answers 'constructor', entry['constructor'] is the Object constructor (truthy), next.label becomes that function, and expect(labelUnder('constructor')).toBe('Created At') fails on the first iteration; '__proto__' would fail the same way on Object.prototype. One it, so one red test. Added to round 0's four (three resolveBundleLocale prototype-name pins and the inherited-key pin), the dev's leg-A count of 5 is what the tree predicts. Leg B (the pickData read) was not rerun and need not be: the file is byte-identical to the head it was run on.
  • The pin is placed where S1 asked. It sits in the round-0 describe at the file's end, before the own-key case, and uses the exported translateObject; import { translateObject } from './i18n-resolver' already exists at test line 3286 (a hoisted ESM import), so the new case needs no import change. The expectations compare to the 'xx' answer first and name the literal second, the shape the card's pins use.

② Semver level

  • @objectstack/spec patch — still right. The level follows the Metadata-protocol: kind alone; it is still none. The delta changes a test and a changeset sentence; nothing the classifier reads moved.
  • The changeset's new sentence is true on the tree. "an object read (/meta) under ?locale=constructor or Accept-Language: constructor served the Object constructor as the label of every platform-injected system field still at its English default (so the label went missing from the JSON), and under __proto__ served Object.prototype there" — that is exactly the path traced above and in 6671371228 S1 (translateObject → translateField → builtinSystemFieldLabel → entry[resolved]), and "those fields keep their English label" is what the pin holds. The declaration lines are unchanged: Clause-②: no, Metadata-protocol: none.
  • Metadata-protocol: none and Clause-②: no still hold. The sentence describes a served answer that was wrong for a request no author wrote; no authored key's meaning moves and no accept set or public surface moves. A changeset that now names a user-visible defect it fixes is a more accurate patch note, not a different kind.

③ Boundary flags

  • PR body, refreshed by the seat (updated_at after the r1 report) — checked against the tree, holds. Census: "seven non-test callers outside packages/spec … plus four inside packages/spec: pickData, builtinSystemFieldLabel, validation-message.ts and operation-message.ts" — matches git grep of the head (7 outside; inside: :602, :4495, validation-message.ts:399, operation-message.ts:332). The two new table rows (pickData fed by the translators' locale chain; builtinSystemFieldLabel from translateObject's field pass) are right, both unvalidated. Impact: "One wrong answer was user-visible, and this PR removes it" with the /meta label path, and the removal of the round-0 "no user-visible sentence changed" claim — right. Tests: the new fourth bullet names the translateObject pin and 'Created At'; the block count (9 cases: 3 codes × 2, inherited, system-field, own-key) and the file total 501 match the tree. The round-1 acceptance note names the review, S1, the pin, the changeset sentence, the byte-identical source and the no-merge reading — right. Clause-②: no and Metadata-protocol: none are on the body and the changeset, unchanged and right (②).
  • No merge of origin/main (dev flag, repeated) — right. The merge base is still b3d2f4bd; origin/main is 22 commits past it at this reading and git log of the range on i18n-resolver.ts, its test and platform-locales.ts is empty. The dev's STALE TREE note from dispatch-gates names derivation inputs, not these files.
  • open_questions: none in report 6672135607 — nothing to answer. The report withdraws the round-0 "latent-contract hardening" claim, as S1 asked.
  • Deviations judged: body-r1.md was dry-run only and the seat sent it (the body on the platform carries the seat signature on line 1 and the corrections) — the body is the seat's own write, read here as a claim and checked above; nothing else is new since round 0.
  • One staleness nit, not a finding: the first acceptance note still says origin/main "has moved 6 commits since", a round-0 reading now at 22; the round-1 note carries the current reading ("moved no commit on" the three files), which is the one that matters.

④ Design judgment

packages/spec/src/system/i18n-resolver.ts — unchanged (blob 405d8b76); the ④ judgment of 6671371228 is carried whole. The delta adds no read and removes none; the two own checks, the ladder over Object.keys, the Map-backed table lookup and the own-keyed bundle[resolved] read are the same bytes. The one thing round 0 left unpinned on the served-document path is now pinned through the exported translateObject, and the pin observes exactly the read that was wrong (entry[resolved] reaching next.label), so leg A of the ablation now reds the served path as well as the resolver.

Must-fix: none.

Should-fix: none. S1 of 6671371228 is closed by this head: the census is corrected, the impact sentence is corrected, the changeset names the label, and the pin is in the tree.

Implemented-by: claude/issue-30619-bundle-own-key
Reviewed-by: session_01L6bhgTqqeHSwZYJmApbDCa

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

维护者速读

  • 改了什么:resolveBundleLocale 的精确快路径与 pickData 的精确读取改为只认翻译包自己的键(own property),constructor / toString / __proto__ 这类原型成员名不再被当成已注册语言。
  • 为什么改:请求语言码(Accept-Language 首个标签或 ?locale=)原样进入解析器,原型成员名会命中任意普通对象翻译包,解析器把它当语言返回;用户可见的一处是 /meta 对象读取时,平台注入的系统字段(如「Created At」)标签丢失。
  • 风险与回滚:两行判断,无签名、导出或 schema 变化;七个外部调用方的返回值实测不变,唯一变化是系统字段标签恢复为英文默认(详见 Callers 表);回滚即还原 i18n-resolver.ts 这两处。
  • 先看:packages/spec/src/system/i18n-resolver.ts 的 resolveBundleLocale 快路径与 pickData 首行;其次 i18n-resolver.test.ts 末尾新增的 describe 块。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/i18n-resolver.test.ts,预期 501 passed,其中包括末尾 own-key 新块的 9 个用例。
  • 需要你做的:无,席位落地。

席位意见:契约复审两轮都 PASS。第一轮唯一的 should-fix 指出,调用点清单漏了内部的 builtinSystemFieldLabel。正因为漏了这一处,原正文说"用户看不到变化"是错的:修复前,用 ?locale=constructor 读对象元数据,平台注入的系统字段会丢标签。第 1 轮补了一颗走公开 translateObject 的钉子,消融时会变红,并改正了正文和变更集。源码没再动。增量复审逐条核对为真。

席位核对 — ACCEPT · domain:spec#3 · head fdba0dbd7563d1da25a725806ed887270211b89f

  • Shape: 3 files (+70 −3), draft, base main, first card reference Fixes #30619. Two branch commits after the round-0 code (a160083be): the round-0 fixture fix, and fdba0dbd7, the round-1 pin plus the changeset sentence.
    • Hand-written:
      • two reads in packages/spec/src/system/i18n-resolver.ts, resolveBundleLocale's exact fast path and pickData's exact read, now go through Object.prototype.hasOwnProperty.call;
      • a new describe block at the end of i18n-resolver.test.ts;
      • the changeset.
    • Nothing is regenerated. There is no signature, export or schema change.
  • Premise measured: at origin/main b8929f48, :526 read bundle[requested] !== undefined and :594 read bundle[locale], with no own-property check. A request code such as constructor or __proto__ hit every plain-object bundle.
    • Seven outside callers pass ExecutionContext.locale or a validated tag. Five of them pass the raw Accept-Language top tag or ?locale=, but each ends ?? requested, so their answers do not move.
    • The one user-visible wrong answer was inside the file: builtinSystemFieldLabel (:4495) returned entry['constructor'], so a /meta object read lost the label of every injected system field still at its English default. This PR removes that.
  • Gates:
  • Contract review:
    • 6671371228 on a160083be: PASS, Served-tier: CONTRACT_REVIEW_TIER, no must-fix. One should-fix: the census missed builtinSystemFieldLabel, so "no user-visible change" was false.
    • Delta 6672224800 on fdba0dbd7: PASS, nothing outstanding. It found:
      • i18n-resolver.ts is byte-identical to round 0 (blob 405d8b76);
      • the new pin reaches builtinSystemFieldLabel through translateObject and goes red with the fast path reverted;
      • the changeset sentence, the corrected census (7 outside, 4 inside) and the impact sentence are true against the tree.
  • Declarations: Clause-②: no, Metadata-protocol: none, @objectstack/spec patch, the same in the body and in the changeset. LocaleTagKeySchema refuses a prototype-name key on every authored bundle, so no authored key's meaning moves. This is a defect removal for a request no author writes, not semantic.
  • Accepted deviations:
    • The pickData pin goes through the exported resolveViewLabel, with an Object.create(bundle) inherited-key pin as the ablation-sensitive one, because pickData is not exported.
    • hasOwnProperty.call instead of Object.hasOwn, because the spec lib target is ES2020; it matches the file's existing reads.
    • origin/main is not merged: it moved nothing on the three files.
    • The round-0 report's "latent-contract hardening" is withdrawn in round 1.
    • The first acceptance note's "main moved 6 commits" is a round-0 reading; the round-1 note carries the current one.
    • mcp_calls: 0 in every round.
  • Carried: nothing filed.
    • resolveBundleLocale({ en: {}, fr: undefined }, 'fr') answers 'fr' through resolveLocaleAmong's Object.keys step. That is pre-existing, and no parsed bundle can carry an undefined value.
    • ExecutionContext.locale is not shape-validated, but no wrong answer is reachable from the resolver after this PR.
      Both are observations in the PR's acceptance notes.
  • Landing: not governed (check-governed-merges --pr 30678: 0 of 3 paths, 73 lines ≤ 3000). The hand-off is pr_ready plus automerge_enable, through the merge queue.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 23:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 23:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit f2ea193 Nov 5, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-30619-bundle-own-key branch November 5, 2026 23:55
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/s tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant