Uh oh!
There was an error while loading. Please reload this page.
fix(app-showcase): author the task-done email at a locale the send ladder can reach - #10419
Merged
os-warren merged 2 commits intoAug 20, 2026
Merged
Conversation
…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-warren
marked this pull request as ready for review
August 20, 2026 18:33
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
Uh oh!
There was an error while loading. Please reload this page.
os-warren
deleted the
claude/issue-10267-showcase-email-template-locale
branch
August 20, 2026 19:47
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#10267
examples/app-showcase/src/system/emails/index.tsdeclaredlocale: '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, soennever satisfiesen-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 realmapTemplateToRowprojection intosys_email_templatecolumns, the realcreateSysEmailTemplateLoader, and the realEmailService.sendTemplateladder: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-USrecipient getsTEMPLATE_NOT_FOUND. On this app it does not, today: the notify path's recipient locale isgetDefaultTemplateLocale()→II18nService.getDefaultLocale(), andexamples/app-showcase/objectstack.config.tsdeclaresdefaultLocale: '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 isen-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:The ladder cannot tell them apart —
defineEmailTemplateDefinitionapplies theen-USdefault, so both produce the identical materialized row. So the measurement settles the part it can and no more:en-USoverendecisively.en-USis the ladder's own second rung, so a row authored there is reachable from every call shape — an expliciten-US, this app'sdefaultLocale: '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 passen.jobandemail_templateare 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: explicitlocale: 'en-US'.The literal is now wrapped in
defineEmailTemplateDefinition(...)— the card's other half — soEmailTemplateDefinitionSchema.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 isdefineEmailTemplateDefinition({ … locale: 'en-US', … })and its locale section namesen→en-USas the anti-pattern in the same breath aszh→zh-CN. The one shape difference left standing is the templatename: the guide recommends a dotted namespace (crm.task_done) and this row keepsshowcase_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'ssuperRefinerefusestemplatecombined with inlinetitle/messageoutright.showcase_task_completed's notify node uses the inline path, and itsmessage: '{summary}'is the only consumer of thesummarizescript node'soutputVariable— which is exactly what that flow's docblock says the flow exists to demonstrate ("A flow function is PURE: it takesinputs, RETURNS a value, and a later declarative node uses or persists it"). Wiring the template would trade one deliberate demonstration for another, would additionally needexpand: ['project']on the start node to feed the template'sprojectvariable, and would need@objectstack/plugin-emailregistered as a runtime plugin or the channel answersTEMPLATE_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:en-USthe loader is the whole ladder: rungs 1 and 2 name the same tag, so a null there is aTEMPLATE_NOT_FOUNDthrow at the service);body_htmlidentity, so a resolution answering the wrong row cannot pass as "not null";enstill 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;allEmailsis reachable at the default locale, so a template added later cannot reintroduce the defect;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:
Restore proven byte-identical, not merely re-run: mutated blob
32c86c89f05851c45e26be4a7d10900e2aeb3c02→ restoredc62a8abcd167dd47e46f112f8e6ffa0c47520921, equal togit 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 builddoes produceexamples/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 nodist/is on that path; and the other package on the path,@objectstack/plugin-email, is aliased invitest.config.tstopackages/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
defineEmailTemplateDefinitionwhile 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 todist/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"), soexamples/app-showcase/vitest.config.tsgains an anchored-regex, array-form entry (rule 5: a bare string key would swallow subpaths into anENOTDIR). The@objectstack/example-showcaseregistry entry is unchanged, and the gate confirms it.Changeset
None — followed the gate, not habit.
examples/app-showcaseisprivate: trueand is not in.changeset/config.json'sfixedrelease set; every file this PR touches is inside it plus the lockfile, so the PR releases nothing. That is this repo's textbookskip-changesetcase (named as such inlint.yml's own rationale), and the empty-frontmatter route used inobjectuiis explicitly rejected here bycheck-empty-changeset.mjs. Theskip-changesetlabel 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 head80a65cb0dafter 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.check:type-source-resolutioncheck-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-inputsOK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:override-consistency✓ 8 published-manifest declaration(s) … all resolve to their override targets.check-changeset-fixed.mjs✓ .changeset/config.json "fixed" group is in sync with 69 public workspace packages.check-osv-exemptions.mjs✓ osv-scanner.toml holds zero OSV exemptions (the intended steady state).check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none newcheck:type-check-coverage✓ check:type-check-coverage --self-test — 23 semantic case(s) … hold.check:engine-double-contractOK self-test: separates engine doubles from driver doubles …check:where-matcher✓ where-matcher conformance holds: 266 matcher(s) discovered, 266 answer the combinator battery correctlycheck:test-source-aliascheck-test-source-alias OK — 72 packages with tests scanned; 61 registered …check:nul-bytescheck-nul-bytes: OK (scanned 6115 text file(s) … no raw ASCII control bytes).check:type-check-debt --re-measurecheck-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 suiteTest Files 22 passed (22) · Tests 348 passed (348). An earlier run of that suite had 10 files red withFailed 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-measurereports none above its recorded number.check:test-source-aliaswas 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 reportedVERDICT command-exit 0.Follow-up —
check:type-source-resolution(CI red on7bfca1da4, fixed in80a65cb0d)The first push went red in
Lint & Repo Gates, legitimately and on this PR's own change: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-emailcontributespackages/plugins/plugin-email/dist/*.d.tsto this app's tsc program, sotsc --noEmitwas 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-resolutionat7bfca1da4exits 1 with the message above, and at80a65cb0dexits 0 withcheck-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
pathsrule the gate asks for, inexamples/app-showcase/tsconfig.json, mirroring the@objectstack/formulaentry already there. The shrink-onlyKNOWN_DIST_RESOLVED_TYPE_IMPORTSregistry is untouched — same discipline this PR already followed forcheck:test-source-alias. Bare key, no*: a tsconfigpathskey 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 typecheckis clean and the suite is stillTest 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-resolutionis a real family (lint.yml:1318) and it is the tsconfig-pathssibling of the vitest-alias gate I did run.node scripts/pm/dispatch-gates.mjsdoes not name it — still does not, even now thatexamples/app-showcase/tsconfig.jsonis 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-showcasehas 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