Measured at @objectstack/spec 17.3.0 / @objectstack/cli 17.3.0, as installed by objectstack-ai/hotcrm at db5fe702.
Filed from the HotCRM seat under the "a platform gap is filed upstream, never compensated for locally" rule. This observation was first seen at 17.1.0 and deliberately not filed then — no reproduction, and #13109 / #13215 were actively reworking these walks. It is filed now because it survives into 17.3.0 and is reproducible against a current version.
The invariant this appears to violate
walkAddressedPageComponents was exported (#13218, completing #13109's precedent) so the resolver and the CLI extractor cannot drift. Its own doc names the failure pair it exists to prevent:
the extractor offering an id the resolver ignores, or omitting one it reads (#13109 was the second half going live)
What follows looks like that second half, still open for one component type.
Measurement
A page with ONE region-level page:header that carries an id (home_header), plus a page:card with a nested child, matching HotCRM's real sales_home_page.
Leg A — the resolver (translatePage, @objectstack/spec/system). Bundle written three ways, locale: 'zh-CN':
1/page-name route only: header.title -> "ZH-by-page-name" (control: harness translates)
2/id route only: header.title -> "ZH-by-ID" <-- the id key IS read
3/BOTH routes present: header.title -> "ZH-by-ID" <-- and it WINS
header.subtitle -> "ZH-sub-by-page-name"
So for a region-level page:header with an id, pages.PAGE.components.HEADERID.title is honoured and takes precedence over pages.PAGE.title. This matches translatePage's own doc ("Because the id route is the more specific of the two, it wins wherever both could apply (a page:header that does carry an id)").
Note subtitle behaves differently: it is not in PAGE_COMPONENT_COPY_KEYS (title, description, label, placeholder, emptyText), so it has no id route at all and only ever resolves by page name. One component, two keys, two different addressing rules.
Leg B — the extractor (collectExpectedEntries, @objectstack/cli/dist/utils/i18n-extract.js). Same page:
pages.sales_home_page.label
pages.sales_home_page.title <-- header copy offered by PAGE NAME
pages.sales_home_page.subtitle
pages.sales_home_page.components.kpi_card.title
pages.sales_home_page.components.kpi_won.label
No components.home_header.* entry. This is deliberate and documented in the extractor:
page:header is deliberately skipped AT REGION LEVEL: its copy is addressed by page name above, and emitting it here too would offer one string under two keys.
Run against HotCRM's real page metadata the same holds: 54 page-scoped keys offered, zero for any of the five region-level page:header ids it authors (home_header, lead_header, case_header, opp_header, launcher_header — all five carry ids).
Leg C — the key is schema-legal. In TranslationBundleSchema, pages is a record keyed by any string; its components is a record keyed by any string with a strict value object declaring title. Nothing distinguishes a page:header id from any other component id, so an author can legally write the key that Leg B never offers and Leg A prefers.
Consequence
An author who writes pages.PAGE.components.HEADERID.title — a natural thing to reach for, since every other component is addressed exactly that way — silently overrides pages.PAGE.title, which is the key os i18n extract offers and os i18n coverage counts. One string, two keys; the one that wins is invisible to the tooling, and the one the tooling reports as translated is the one that loses.
Both sides are deliberate, so this is a contract call, not an obvious bug
The extractor's rationale (do not offer one string under two keys) and the resolver's rationale (the more specific route wins) are each sound alone. They just disagree about which key is canonical for a region-level header's title. Two candidate resolutions, both cheap:
- Resolver stops reading the id route for a region-level
page:header — makes the page-name route canonical, matching what the extractor offers. Nested page:header components stay id-only, which translatePage already documents ("a page:header nested inside a container is not it"). Behaviour change for anyone relying on the override today.
- Extractor offers the id key too — keeps the resolver's precedence, at the cost the extractor comment names.
I have no basis to prefer one; the precedence rule is a published contract and this seat does not own it. Deferring to whoever owns the #13109 / #13218 line.
Not measured
Whether the served /i18n/translations catalog endpoint mirrors the extractor exactly. Both legs above are direct function-level readings against the installed packages, not a running server.
Origin: objectstack-ai/hotcrm#1376 (verification card), first recorded in objectstack-ai/hotcrm#1004.
Measured at
@objectstack/spec17.3.0 /@objectstack/cli17.3.0, as installed byobjectstack-ai/hotcrmatdb5fe702.Filed from the HotCRM seat under the "a platform gap is filed upstream, never compensated for locally" rule. This observation was first seen at 17.1.0 and deliberately not filed then — no reproduction, and #13109 / #13215 were actively reworking these walks. It is filed now because it survives into 17.3.0 and is reproducible against a current version.
The invariant this appears to violate
walkAddressedPageComponentswas exported (#13218, completing #13109's precedent) so the resolver and the CLI extractor cannot drift. Its own doc names the failure pair it exists to prevent:What follows looks like that second half, still open for one component type.
Measurement
A page with ONE region-level
page:headerthat carries an id (home_header), plus apage:cardwith a nested child, matching HotCRM's realsales_home_page.Leg A — the resolver (
translatePage,@objectstack/spec/system). Bundle written three ways,locale: 'zh-CN':So for a region-level
page:headerwith an id,pages.PAGE.components.HEADERID.titleis honoured and takes precedence overpages.PAGE.title. This matchestranslatePage's own doc ("Because the id route is the more specific of the two, it wins wherever both could apply (apage:headerthat does carry an id)").Note
subtitlebehaves differently: it is not inPAGE_COMPONENT_COPY_KEYS(title,description,label,placeholder,emptyText), so it has no id route at all and only ever resolves by page name. One component, two keys, two different addressing rules.Leg B — the extractor (
collectExpectedEntries,@objectstack/cli/dist/utils/i18n-extract.js). Same page:No
components.home_header.*entry. This is deliberate and documented in the extractor:Run against HotCRM's real page metadata the same holds: 54 page-scoped keys offered, zero for any of the five region-level
page:headerids it authors (home_header,lead_header,case_header,opp_header,launcher_header— all five carry ids).Leg C — the key is schema-legal. In
TranslationBundleSchema,pagesis a record keyed by any string; itscomponentsis a record keyed by any string with a strict value object declaringtitle. Nothing distinguishes apage:headerid from any other component id, so an author can legally write the key that Leg B never offers and Leg A prefers.Consequence
An author who writes
pages.PAGE.components.HEADERID.title— a natural thing to reach for, since every other component is addressed exactly that way — silently overridespages.PAGE.title, which is the keyos i18n extractoffers andos i18n coveragecounts. One string, two keys; the one that wins is invisible to the tooling, and the one the tooling reports as translated is the one that loses.Both sides are deliberate, so this is a contract call, not an obvious bug
The extractor's rationale (do not offer one string under two keys) and the resolver's rationale (the more specific route wins) are each sound alone. They just disagree about which key is canonical for a region-level header's
title. Two candidate resolutions, both cheap:page:header— makes the page-name route canonical, matching what the extractor offers. Nestedpage:headercomponents stay id-only, whichtranslatePagealready documents ("apage:headernested inside a container is not it"). Behaviour change for anyone relying on the override today.I have no basis to prefer one; the precedence rule is a published contract and this seat does not own it. Deferring to whoever owns the #13109 / #13218 line.
Not measured
Whether the served
/i18n/translationscatalog endpoint mirrors the extractor exactly. Both legs above are direct function-level readings against the installed packages, not a running server.Origin: objectstack-ai/hotcrm#1376 (verification card), first recorded in objectstack-ai/hotcrm#1004.