Skip to content

docs(email): three shipped carriers said 'best-matching locale'; the resolver matches (name, locale) exactly - #19504

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-18499-email-locale-docblocks
Sep 21, 2026
Merged

huangyiirene merged 2 commits into
mainfrom
claude/issue-18499-email-locale-docblocks

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #18499

Clause-②: no

Three shipped carriers still told readers that the EmailService "picks the best-matching locale" — the exact sentence packages/plugins/plugin-email/src/template-loader.ts enumerates as a false declaration. This PR replaces that prose with what the resolver does. No resolution code changes; packages/spec is zero files in this diff.

1. The resolver, read at this branch's base (4045781fa) — the criterion for every rewrite

Card line numbers were not trusted; every reading below was taken on today's head.

packages/plugins/plugin-email/src/template-loader.ts — the loader (base lines 110-118, this branch's lines 119-124):

    async load(name, locale) {
      if (locale) return first({ name, locale }, BY_ID);
      const preferred = await first({ name, locale: DEFAULT_TEMPLATE_LOCALE }, BY_ID);
      if (preferred) return preferred;
      // No en-US row in this bundle — rule 3 in the module doc.
      return first({ name }, BY_LOCALE);
    },

with the two order lists it pins the row by (lines 80, 83-86):

const BY_ID: readonly TemplateSort[] = [{ field: 'id', order: 'asc' }];

const BY_LOCALE: readonly TemplateSort[] = [
  { field: 'locale', order: 'asc' },
  { field: 'id', order: 'asc' },
];

packages/plugins/plugin-email/src/email-service.ts — resolveAndRenderTemplate, the ladder above the loader (lines 1317-1332, unchanged by this PR):

    const preferred = input.locale && String(input.locale).trim();
    const wanted = preferred || DEFAULT_TEMPLATE_LOCALE;
    let row = await loader.load(input.template, wanted);
    if (!row && wanted !== DEFAULT_TEMPLATE_LOCALE) {
      row = await loader.load(input.template, DEFAULT_TEMPLATE_LOCALE);
    }
    if (!row && !preferred) {
      row = await loader.load(input.template, undefined);
    }
    if (!row) {
      throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=${wanted})`);
    }

DEFAULT_TEMPLATE_LOCALE is the literal 'en-US' (email-service.ts:463).

So, mechanically:

  • a call that NAMES a locale gets an exact { name, locale } match, then exactly one retry at the literal 'en-US', then TEMPLATE_NOT_FOUND. if (!row && !preferred) gates the third rung out for it;
  • a call that names NO locale starts AT 'en-US' (the ladder passes wanted, and the loader's own no-locale branch also queries 'en-US' first), and only when the bundle carries no en-US row at all does it take the bundle's lowest locale tag, ordered;
  • nothing on that path folds a language subtag, and nothing ranks candidate rows. There is no "best match" to pick.

2. Carrier by carrier: before, after, and the line that decides it

(a) packages/platform-objects/src/audit/sys-email-template.object.ts, base lines 10-11

Before: "Resolved by (name, locale); the EmailService picks the best-matching locale for the recipient, falling back to en-US."

After (head lines 10-18): resolved by an EXACT (name, locale) match, no language-subtag folding, one literal en-US rung for a call that names a locale and then TEMPLATE_NOT_FOUND; a call naming none starts at en-US and falls to the bundle's lowest tag only when no en-US row exists.

Decided by: template-loader.ts:120 (if (locale) return first({ name, locale }, BY_ID)) plus email-service.ts:1320-1323 (the single retry, gated off the third rung by !preferred).

(b) packages/services/service-messaging/src/objects/notification-template.object.ts, base line 65

Before: "both resolve a template by best-matching locale."

After (head lines 63-71): the 16-char BCP-47 BOUND is shared with sys_email_template; the RESOLUTION is not, and neither side picks a best match. NotificationTemplateStore walks a fixed candidate list; sys_email_template matches exactly and folds nothing.

Decided by: packages/services/service-messaging/src/template-renderer.ts:131-140, localeCandidates — it pushes the named tag, then locale.split('-')[0], then DEFAULT_LOCALE (template-renderer.ts:9, the literal 'en'). That sentence was false twice over: not only is there no best match, the two resolvers do not behave alike — this one DOES fold a primary subtag, and the email one never does.

(c) docs/qa/platform-checklist/areas/integration-system.json, line 818 (item integration-system.email-template-render, acceptance clause 4)

Before: "(name, locale) resolution picks the best locale row and falls back to en-US — two rows with one name are an i18n bundle, both reachable by recipient locale".

After: an EXACT match with no language-subtag folding; each bundle row reachable only from its own exact tag; a named locale with no row retries the single literal rung en-US and then raises TEMPLATE_NOT_FOUND; a call naming no locale starts at en-US. The clause's verify line was corrected with it — "the unmatched recipient gets the fallback" now names what unmatched means and what must NOT render (a zh recipient gets the en-US row, not the zh-CN one), because the old wording would have ticked either outcome. Item revision 4 goes to 5 with a history entry, per docs/qa/platform-checklist/README.md.

Decided by: the same two code sites as (a).

3. A fourth file is edited, and why it is not scope creep

packages/plugins/plugin-email/src/template-loader.ts (the authority text) quoted carrier (a) verbatim in its "What was wrong" block, so correcting (a) would have stranded that quotation — the exact defect the card records against packages/metadata-core/src/item-key-discriminators.ts. Measured while making the edit: one of the block's three bullets was ALREADY stale at this base. It attributes to EmailTemplateDefinitionSchema.locale the sentence "the service picks the best match for the recipient's locale, falling back to en-US", and grep -nE "best match|best-matching" packages/spec/src/system/email-template.zod.ts exits 1 — zero occurrences. Both bullets are now cited rather than quoted at length, so a later rewording cannot strand them again. No packages/spec file is touched.

Note for the seat: the dispatch named template-loader.ts as one of "the three carriers" and did not name docs/qa/platform-checklist/areas/integration-system.json. The card body and the triage comment name the other way round — the checklist JSON is a carrier, template-loader.ts is the contract text. This PR follows the card and the triage comment, and edits template-loader.ts as a consequence of (a) rather than as a carrier, which is why the diff is four files rather than three.

4. Verification

Base 4045781faff6ef4811b7438aee009be750d7cdde; readings below taken on this branch.

  • Derived gate families: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack over the real change set, reconciled with --ran: 58 derived, 57 run and green, 1 NOT MEASURED, 0 unrun. The one NOT MEASURED is pnpm check:dual-build-cjs-loads, exit 3 (PREREQUISITE NOT MET — it needs a whole-repo pnpm build; 43-plus packages outside this diff's closure have no dist/). That is CI's run, not a finding and not a pass.
  • pnpm check:platform-checklist — exit 0 (the gate that ratchets the checklist item's revision against its history).
  • pnpm check:i18n and pnpm check:i18n-stale-fill — exit 0, after building the closure the gate names. No label, description or help string is touched by this diff, so no translation bundle moves.
  • Build: turbo run build over @objectstack/platform-objects, @objectstack/plugin-email, @objectstack/service-messaging and their dependency closure — 19 tasks successful; then the i18n gate's own closure — 57 tasks successful.
  • Tests and typecheck for the three affected packages: turbo run typecheck test — 23 tasks successful. platform-objects 47 files / 668 tests, service-messaging 44 files / 479 tests, plugin-email 30 files / 468 tests, all passed.
  • pnpm lint (the repo-wide eslint . --no-inline-config) — exit 0. Run whole, so no narrowing needs declaring.
  • Control-character self-scan over all five touched files: grep -naP over the C0 set plus DEL, exit 1 (no match).
  • Heavy runs went through scripts/pm/os-verify-lock.sh with a stable slot; every verdict read off its VERDICT command-exit line.

Pin tests on the three sentences: there are none. Probed best-matching locale|picks the best repo-wide (12 hits, of which 6 are CHANGELOG history and one is the prior round's changeset) and grepped the test files that name these two object modules — sys-email-template.organization-unique.test.ts, managed-by-system-data.test.ts, notification-keyed-text-bounds.test.ts — none asserts docblock or comment text. docs/qa/platform-checklist/coverage.json references the item by id only, and the id is unchanged. No new verification surface was invented to stand in for the absent pins.

5. Acceptance notes

  • packages/metadata-core/src/item-key-discriminators.ts:7-11 still quotes EmailTemplateDefinitionSchema as saying the service "picks the best match for the recipient's locale, falling back to en-US" — a sentence with zero occurrences in packages/spec/src/system/email-template.zod.ts today. The card records this deliberately as a stale quotation and explicitly not a second instance of the false claim, so it is left untouched here rather than widening the diff into domain:engine's lane. Reported for the seat.
  • packages/plugins/plugin-email/src/email-service.ts:465-467, the TemplateLoader interface docblock, opens with "Returns the best-matching row for (name, locale)" and then, two lines later, states the exact-match rule correctly. It is the same false phrase family; it escaped the card's probe only because it reads "best-matching row" rather than "best-matching locale". Left out of this diff because the card enumerated its carrier set from that probe and this file is not in it. Reported for the seat.
  • The checklist item's variants[0] already read "exact (name, locale) match / en-US fallback", i.e. the item contradicted itself between its variants list and its acceptance clause. Only the clause was wrong; the variant is left as it stands.

Generated by Claude Code

…resolver matches (name, locale) exactly

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tooling labels Sep 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/platform-objects/src/audit/sys-email-template.object.ts, packages/plugins/plugin-email/src/template-loader.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/platform-objects/src/audit/sys-email-template.object.ts, packages/plugins/plugin-email/src/template-loader.ts) — pages documenting those are invisible to this run
  • 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 — 10 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 2cac3636cab1cecef8f7a0453e4963dd1d01fb21 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3c2fdc15de404cbeec0110c028e203f98769ad19 — the merge of head 380636ac44cfa984e26d71c332acd74da2da612a into base 2cac3636cab1cecef8f7a0453e4963dd1d01fb21, 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 3c2fdc15de404cbeec0110c028e203f98769ad19 && git checkout 3c2fdc15de404cbeec0110c028e203f98769ad19
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2cac3636cab1cecef8f7a0453e4963dd1d01fb21 380636ac44cfa984e26d71c332acd74da2da612a && git checkout -B drift-repro 2cac3636cab1cecef8f7a0453e4963dd1d01fb21 && git merge --no-ff 380636ac44cfa984e26d71c332acd74da2da612a

node scripts/docs-audit/affected-docs.mjs --json 2cac3636cab1cecef8f7a0453e4963dd1d01fb21

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

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/s tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

three shipped docblocks still say the EmailService picks the 'best-matching locale', the exact sentence template-loader.ts names as false

2 participants