Skip to content

fix(app-showcase): author the task-done email at a locale the send ladder can reach - #10419

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-10267-showcase-email-template-locale
Aug 20, 2026
Merged

fix(app-showcase): author the task-done email at a locale the send ladder can reach#10419
os-warren merged 2 commits into
mainfrom
claude/issue-10267-showcase-email-template-locale

Conversation

@os-warren

@os-warrenos-warren commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes#10267

examples/app-showcase/src/system/emails/index.ts declared locale: 'en' on a bare object literal. sendTemplate's ladder is exact match → en-US → (no-locale calls only) the bundle's lowest tag, with deliberately no language-prefix matching, so en never satisfies en-US.

The premise, measured rather than inferred

The card's premise is behavioural, so it was exercised rather than read off the strings. The unmodified declaration was pushed through the real boot path — EmailTemplateDefinitionSchema.parse(), the real mapTemplateToRow projection into sys_email_template columns, the real createSysEmailTemplateLoader, and the real EmailService.sendTemplate ladder:

materialized row: { name: showcase_task_done_email, locale: "en", subject: "✅ Task done: {{title}}" }
loader.load(showcase_task_done_email, en-US) -> null
loader.load(showcase_task_done_email, en) -> row locale=en
loader.load(showcase_task_done_email, undefined) -> row locale=en
sendTemplate no locale -> RESOLVED "✅ Task done: Ship it" (rung 3, the lowest-tag fallback)
sendTemplate locale='en-US' -> THREW TEMPLATE_NOT_FOUND: showcase_task_done_email (locale=en-US)
sendTemplate locale='en' -> RESOLVED "✅ Task done: Ship it"
sendTemplate locale='zh-CN' -> THREW TEMPLATE_NOT_FOUND: showcase_task_done_email (locale=zh-CN)

Premise confirmed: rows 1 and 2 of the card's table reproduce exactly.

One row of the card's table does not hold as written, and it is worth recording. The card says a notify delivery for an en-US recipient gets TEMPLATE_NOT_FOUND. On this app it does not, today: the notify path's recipient locale is getDefaultTemplateLocale()II18nService.getDefaultLocale(), and examples/app-showcase/objectstack.config.ts declares defaultLocale: 'en'. So the showcase's own deliveries pass 'en' and currently resolve. The defect is real and the first two rows are enough for it; the third is conditional on a deployment whose default tag is en-US. The fix is correct for both, as the next section shows.

Which remedy, argued from the measurement

The card left the choice between locale: 'en-US' and dropping the key to take the schema default. Both candidates were run through the same real ladder:

A · explicit locale: "en-US" row.locale="en-US" send(en-US) OK · send(en) OK · send(no locale) OK
B · key dropped, factory default row.locale="en-US" send(en-US) OK · send(en) OK · send(no locale) OK

The ladder cannot tell them apartdefineEmailTemplateDefinition applies the en-US default, so both produce the identical materialized row. So the measurement settles the part it can and no more:

  • It settles en-USover en decisively. en-US is the ladder's own second rung, so a row authored there is reachable from every call shape — an explicit en-US, this app's defaultLocale: 'en' (via rung 2), and a call naming no locale. Any other tag is reachable from strictly fewer. That is why the fix is right for the showcase even though the showcase's own callers pass en.
  • It does not settle explicit-versus-default, because they are the same row. That was decided on the axis the measurement leaves open: the example corpus is what customers and AI authors copy, the tag is the bundle key a second language row must match, and the guide in docs(automation): job and email_template are authorable, referenced, and have no how-to page #10211 writes it out in both of its samples under the rule "author the tags your callers actually pass." An omitted key teaches nothing about the key. Chosen: explicit locale: 'en-US'.

The literal is now wrapped in defineEmailTemplateDefinition(...) — the card's other half — so EmailTemplateDefinitionSchema.parse() runs at authoring time instead of first at boot.

Does the shape this lands match what #10211 teaches?

Yes — read from the guide's own branch (claude/issue-10211-jobs-email-templates, PR #10273), not from memory. Its sample is defineEmailTemplateDefinition({ … locale: 'en-US', … }) and its locale section names enen-US as the anti-pattern in the same breath as zhzh-CN. The one shape difference left standing is the template name: the guide recommends a dotted namespace (crm.task_done) and this row keeps showcase_task_done_email. That is schema-valid (the regex makes the dotted segments optional), it is already app-prefixed, and it is not the anti-pattern the guide warns about — renaming it would be unrelated churn on a key the flow wiring question (#10394) may want to settle anyway.

The latent half — deliberately NOT taken, and why

The card scoped "wire the template into the Task Completed flow" as a separate call. Out of scope, because it is not an addition:

NotifyConfigSchema's superRefine refuses template combined with inline title/message outright. showcase_task_completed's notify node uses the inline path, and its message: '{summary}' is the only consumer of the summarize script node's outputVariable — which is exactly what that flow's docblock says the flow exists to demonstrate ("A flow function is PURE: it takes inputs, RETURNS a value, and a later declarative node uses or persists it"). Wiring the template would trade one deliberate demonstration for another, would additionally need expand: ['project'] on the start node to feed the template's project variable, and would need @objectstack/plugin-email registered as a runtime plugin or the channel answers TEMPLATE_UNSUPPORTED.

That is a design decision with real options, not a mechanical edit, so it is filed unassigned as #10394 with the three options priced, and the template's docblock now says it is unwired instead of claiming it is "fired by the Task Completed flow".

Direction pins

examples/app-showcase/test/email-template-locale.test.ts (6 tests) resolves the declared templates through the real loader and the real column mapping — nothing inspects the source literal's strings:

  • the send that used to fail now resolves (for an explicit en-US the loader is the whole ladder: rungs 1 and 2 name the same tag, so a null there is a TEMPLATE_NOT_FOUND throw at the service);
  • the resolved row is this template — name, subject and body_html identity, so a resolution answering the wrong row cannot pass as "not null";
  • a no-locale send is now answered by rung 2 rather than by rung 3's lowest-tag fallback;
  • en still resolves to nothing, pinned in the true direction — there is no prefix matching either way, and it does not need to, because rung 2 catches it;
  • class guard: every template in allEmails is reachable at the default locale, so a template added later cannot reintroduce the defect;
  • every declared template is a fixed point of the schema parse — the detectable consequence of going through the factory.

Ablation

Predicted signature was written down before the mutation (the run log): 5 fail / 1 pass, including one failure in the opposite direction. Observed, exactly:

× resolves `showcase_task_done_email` for an explicit en-US send expected null not to be null
× resolves THIS template, not some other row of the bundle expected undefined to be 'showcase_task_done_email'
× answers a no-locale send from the default rung, not the lowest-tag expected 'en' to be 'en-US'
× does NOT answer the language-only tag `en` expected { id: 'row-0', …(10) } to be null ← reversed: it goes red by RESOLVING
× every declared template in the corpus is reachable at the default showcase_task_done_email is unreachable at en-US
✓ every declared template went through `defineEmailTemplateDefinition` (orthogonal — still wrapped)
Tests 5 failed | 1 passed (6)

Restore proven byte-identical, not merely re-run: mutated blob 32c86c89f05851c45e26be4a7d10900e2aeb3c02 → restored c62a8abcd167dd47e46f112f8e6ffa0c47520921, equal to git rev-parse HEAD:examples/app-showcase/src/system/emails/index.ts, worktree clean, 6/6 green again.

Rebuild statement, corrected against a measurement rather than left as first written. The first rationale said "app-showcase has no dist/". That was true of the worktree at the time and is not the load-bearing fact — pnpm build does produce examples/app-showcase/dist (gitignored). The fact that actually holds: the mutated file is reached through the relative specifier ../src/system/emails/index.js, which vitest transforms from source, so no package resolution and no dist/ is on that path; and the other package on the path, @objectstack/plugin-email, is aliased in vitest.config.ts to packages/plugins/plugin-email/src/index.ts, also source. Both legs were re-run with the dist present and behaved identically.

A second ablation measured whether the factory-bypass half is really guarded, and the honest answer is "partly": unwrapping defineEmailTemplateDefinition while spelling every defaulted key stays green (a complete literal is still a fixed point of the parse); unwrapping and omitting the defaulted keys — the realistic bypass, and the one that costs something — goes red on exactly the fixed-point assertion (expected { …(10) } to deeply equal { …(8) }). The guard is on the consequence, not on the wrapper. Recorded rather than claimed as full coverage.

Why a vitest alias and not a registry entry

The test imports @objectstack/plugin-email, which resolves to dist/ through the workspace link — a stale dist would grade these declarations against an old locale ladder, which is the one thing they exist to measure. check:test-source-alias's registry is shrink-only and its header names the alias as the remedy ("Adding an entry, or widening one, is not how a red build gets fixed"), so examples/app-showcase/vitest.config.ts gains an anchored-regex, array-form entry (rule 5: a bare string key would swallow subpaths into an ENOTDIR). The @objectstack/example-showcase registry entry is unchanged, and the gate confirms it.

Changeset

None — followed the gate, not habit.examples/app-showcase is private: true and is not in .changeset/config.json's fixed release set; every file this PR touches is inside it plus the lockfile, so the PR releases nothing. That is this repo's textbook skip-changeset case (named as such in lint.yml's own rationale), and the empty-frontmatter route used in objectui is explicitly rejected here by check-empty-changeset.mjs. The skip-changeset label is applied and read back.

Gate union

Derived after the final commit on a clean worktree with node scripts/pm/dispatch-gates.mjs (no paths passed — the script takes its own change set from the merge base). Re-derived and re-run in full at the new head 80a65cb0d after the follow-up commit below; every verdict below is from that run, and the ratchet families were re-read there rather than carried over. Exit codes captured before any pipe; each quoted from the gate's own verdict line.

gateexitits own verdict line
check:type-source-resolution0check-type-source-resolution OK — 76 packages with a tsconfig.json scanned; 51 registered as still resolving a workspace dep's types through dist/.
check:cross-package-test-inputs0OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check:override-consistency0✓ 8 published-manifest declaration(s) … all resolve to their override targets.
check-changeset-fixed.mjs0✓ .changeset/config.json "fixed" group is in sync with 69 public workspace packages.
check-osv-exemptions.mjs0✓ osv-scanner.toml holds zero OSV exemptions (the intended steady state).
check:query-options-erasure0✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new
check:type-check-coverage0✓ check:type-check-coverage --self-test — 23 semantic case(s) … hold.
check:engine-double-contract0OK self-test: separates engine doubles from driver doubles …
check:where-matcher0✓ where-matcher conformance holds: 266 matcher(s) discovered, 266 answer the combinator battery correctly
check:test-source-alias0check-test-source-alias OK — 72 packages with tests scanned; 61 registered …
check:nul-bytes0check-nul-bytes: OK (scanned 6115 text file(s) … no raw ASCII control bytes).
check:type-check-debt --re-measure0check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 227.4s, 1924 raw tsc error(s) total, none above its recorded number.

Package-level verification on the built closure: pnpm --filter @objectstack/example-showcase typecheck (tsc --noEmit) clean, and the full suite Test Files 22 passed (22) · Tests 348 passed (348). An earlier run of that suite had 10 files red with Failed to resolve entry for package "@objectstack/connector-mcp" — unbuilt dependency dist, not this diff; building the closure first turned all 22 green.

No ledger entry was raised anywhere: check:type-check-coverage's counts are unchanged and --re-measure reports none above its recorded number.

check:test-source-alias was added by me — the derivation did not name it, and I edited exactly its subject. The union is a clue, not a spec.

Builds and the suite ran through scripts/pm/os-verify-lock.sh; each reported VERDICT command-exit 0.

Follow-up — check:type-source-resolution (CI red on 7bfca1da4, fixed in 80a65cb0d)

The first push went red in Lint & Repo Gates, legitimately and on this PR's own change:

check-type-source-resolution FAILED
✗ @objectstack/example-showcase: NEW dist-resolved type import(s) since this entry was measured: @objectstack/plugin-email.
Add the `paths` rules to the package's tsconfig.json — widening the registry entry is not the fix.

The vitest alias above closed the runtime half of the staleness exposure this new devDependency creates. It has a second one on the type path: unaliased, @objectstack/plugin-email contributes packages/plugins/plugin-email/dist/*.d.ts to this app's tsc program, so tsc --noEmit was grading the declarations against whatever was last built rather than against the locale ladder in this checkout — and the dangerous direction there is a typecheck that passes.

Reproduced before fixing rather than fixed on faith: pnpm check:type-source-resolution at 7bfca1da4 exits 1 with the message above, and at 80a65cb0d exits 0 with check-type-source-resolution OK — 76 packages with a tsconfig.json scanned; 51 registered as still resolving a workspace dep's types through 'dist/'.

The remedy is the paths rule the gate asks for, in examples/app-showcase/tsconfig.json, mirroring the @objectstack/formula entry already there. The shrink-only KNOWN_DIST_RESOLVED_TYPE_IMPORTS registry is untouched — same discipline this PR already followed for check:test-source-alias. Bare key, no *: a tsconfig paths key without a star is an exact match, whereas the @objectstack/plugin-email* spelling (star not preceded by a slash) would fold every subpath onto one target and type-check green against the wrong module — the trap that gate's own header calls out.

Pulling plugin-email's source into the tsc program was the risk worth measuring, and it costs nothing: pnpm --filter @objectstack/example-showcase typecheck is clean and the suite is still Test Files 22 passed (22) · Tests 348 passed (348).

Why the derivation missed it, recorded as a data point rather than an excuse.check:type-source-resolution is a real family (lint.yml:1318) and it is the tsconfig-paths sibling of the vitest-alias gate I did run. node scripts/pm/dispatch-gates.mjs does not name it — still does not, even now that examples/app-showcase/tsconfig.json is in the change set (zero mentions in the derivation output, against a control term present once). That is the class #10309 already tracks; no new card filed.

A note on this package going red in CI

@objectstack/example-showcase has a tracked, non-reproducing CI failure (#10293, p1). If this PR reds there, that is the known signature and attribution needs a control holding the base fixed — without one the honest verdict is "not attributable", in either direction.


Generated by Claude Code

…dder can reach
`examples/app-showcase/src/system/emails/index.ts` declared `locale: 'en'` on a
bare object literal. `sendTemplate`'s ladder is exact match -> `en-US` ->
(no-locale calls only) the bundle's lowest tag, with deliberately no
language-prefix matching, so `en` never satisfies `en-US`.
Measured against the old declaration, through the real loader and the real
`mapTemplateToRow` projection:
loader.load(showcase_task_done_email, en-US) -> null
sendTemplate locale='en-US' -> THREW TEMPLATE_NOT_FOUND: showcase_task_done_email (locale=en-US)
sendTemplate no locale -> RESOLVED (rung 3, the lowest-tag fallback)
`en-US` is the ladder's own second rung, so a row authored there is reachable
from every call shape; any other tag is reachable from strictly fewer. The key
is written out rather than left to the schema default because the example
corpus is what gets copied.
The literal is also now wrapped in `defineEmailTemplateDefinition(...)`, so
`EmailTemplateDefinitionSchema.parse()` runs at authoring time instead of first
at boot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@os-warrenos-warren added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m labels Aug 20, 2026 — with Claude
@github-actionsgithub-actionsBot added dependencies Pull requests that update a dependency file tests labels Aug 20, 2026
@os-warren
os-warren marked this pull request as ready for review August 20, 2026 18:33
@os-warren
os-warren enabled auto-merge August 20, 2026 18:33
…just its runtime
The vitest source alias added alongside the locale fix closed the runtime half of
the staleness exposure. The new devDependency has a second one on the TYPE path:
unaliased, `@objectstack/plugin-email` contributes
`packages/plugins/plugin-email/dist/*.d.ts` to this app's tsc program, so
`tsc --noEmit` graded the declarations against whatever was last built rather
than against the locale ladder in this checkout.
Reproduced before the fix, on 7bfca1d:
check-type-source-resolution FAILED
✗ @objectstack/example-showcase: NEW dist-resolved type import(s) since
this entry was measured: @objectstack/plugin-email.
and after:
check-type-source-resolution OK — 76 packages with a tsconfig.json scanned;
51 registered as still resolving a workspace dep's types through `dist/`.
The remedy is the `paths` rule the gate asks for, mirroring the existing
`@objectstack/formula` entry; the shrink-only KNOWN_DIST_RESOLVED_TYPE_IMPORTS
registry is untouched. Bare key, no `*` — a tsconfig `paths` key without a star
is an exact match, while the `@objectstack/plugin-email*` spelling would fold
every subpath onto one target and type-check green against the wrong module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@os-warren
os-warren added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit 289f727Aug 20, 2026
31 checks passed
@os-warren
os-warren deleted the claude/issue-10267-showcase-email-template-locale branch August 20, 2026 19:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filesize/mskip-changesetPR has no user-facing published change; bypasses the changeset gateteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app-showcase's email template declares locale: 'en', which no en-US send can resolve — and it bypasses defineEmailTemplateDefinition

1 participant

@os-warren