You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2d892dd
Browse filesBrowse the repository at this point in the historyBrowse files
docs(spec): scope the email-template locale-floor claims to a call that names a locale (#18482)
Fixes#18056
`packages/spec` stated **two different rung counts for one resolution**.
This
decides which text is wrong **by measurement against the runtime**, not
by which
was easier to edit, and pins the floor guard's two silent early-returns
without
changing what it warns about.
`Clause-②: yes` — the card's claim comment (`5698961929`) is the
carrier; this
body restates it for legibility. The changeset is `minor`, which that
declaration
requires, and the diff genuinely ships (measured below).
## Remediation round (text-only, no logic moved)
An isolated at-tier contract review of head `708595dc9c` returned FAIL
on the
**completeness of the surrounding claims**, not on the work: the
three-rung reading, the
untouched docs page and the Half B ablation all verified. `642da7fda5`
closes the five
required items, every one of them text:
1. `.changeset/email-template-locale-floor.md` — the **pending,
unreleased** note from
#17884 (`a61ae59f93`, 2026-09-12: the only commit that has ever touched
that file, and
its `--diff-filter=A` add) still said *"retries exactly one rung — the
literal
`en-US`"* and *"the resolver's sole retry rung"*, unscoped. `changeset
version` would
have compiled both **verbatim into the published
`packages/spec/CHANGELOG.md`** — which
that package ships in its `files[]` — beside this PR's correction of
them: the
erratum-in-a-later-entry form `AGENTS.md:686` forbids. Both are now
scoped to a call
that NAMES a locale, with a pointer to `SendTemplateInput.locale` for
the full ladder;
the same file's *"the single literal `en-US` rung"* is scoped for the
same reason.
2. `packages/spec/src/stack-email-template-locale-floor.test.ts:12-14` —
header sentence
scoped, with the no-locale case named beside it. Its title line's bare
*"no fallback
floor"* is scoped the same way.
3.
`packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts:33`
— *"its
single retry rung"* now names both rungs and which call shape reaches
each.
4. The changeset **and** this body now state that the guard's warning
TEXT changed — see
Half B, where *"byte-for-byte"* is gone and *"control flow unchanged"*
stays.
5. The acceptance note on `sys-email-template.object.ts` is corrected —
see Acceptance
notes.
⛔ **`Check Changeset` is RED on this head by design, and must not be
turned green.** The
gate refuses because `.changeset/email-template-locale-floor.md` exists
on the merge base
and was not added by this PR. Item 1 is its **DELIBERATE CORRECTION**
arm, ⛔ not its
COLLISION arm — so the gate's own step 1, restoring that file from the
base, is the one
thing not to do here: it would republish the sentence this PR proves
false. Restoring it,
renaming the edit into a second changeset, and labelling around it were
each considered
and rejected; the middle one is the erratum shape, and `skip-changeset`
suppressing this
very refusal is itself an open finding (#18375). The blob is
byte-identical on the merge
base and on `origin/main` (`13881152ee`), so no concurrent note is being
clobbered. The
gate stays red until a human confirms the correction; that confirmation
is the seat's and
is requested in its own comment.
**No logic line moved — measured, not asserted.** Both `.ts` files were
reprinted through
the TypeScript printer with `removeComments` and hashed: stripped sha256
`c026b27d…` and
`98e79f85…`, byte-identical before and after, while both raw file hashes
changed. Dark
control on a pair that really does move code (`8fe5cb8e51..708595d`
on `stack.zod.ts`)
reads DIFFERENT, so the instrument is not blind. Independently: all 24
changed lines in
those two files match a comment-line shape, and the same filter over
that known-logic diff
finds 12 non-comment lines.
## Half A — which text is wrong
Read against `EmailService.resolveAndRenderTemplate` and
`createSysEmailTemplateLoader` in `@objectstack/plugin-email`:
| rung | condition | outcome |
|---|---|---|
| 1 | the named locale, matched exactly | that row; no language-subtag
folding |
| 2 | the literal `en-US` — also where a call naming **no** locale
starts | that row |
| 3 | **only** when the call named no locale, and the bundle has no
`en-US` row | the bundle's **lowest locale tag**, rendered silently |
A call that **names** a locale never reaches rung 3: it dead-letters
with
`TEMPLATE_NOT_FOUND` (permanent). A call that names **none** never
dead-letters
on a non-empty bundle.
**Verdict: `contracts/email-service.ts` was right and
`system/email-template.zod.ts`
was wrong.** Four independent statements agree with the code, one did
not:
- ✅ `SendTemplateInput.locale` (`contracts/email-service.ts`) — three
rungs, correct.
- ✅ `content/docs/automation/email-templates.mdx` — three rungs,
correct, including
"A call that names a locale with no exact row and no `en-US` row fails
with
`TEMPLATE_NOT_FOUND`".
- ✅ `examples/app-showcase/src/system/emails/index.ts` — "exact match →
`en-US` →
(no-locale calls only) the bundle's lowest tag".
- ✅ The CI pin `plugin-email/src/template-locale-resolution.test.ts` —
rung 3
resolving for a no-locale call, and explicitly **not** widened for a
named one
("an explicit locale is NOT widened to 'any row' when neither it nor
en-US exists").
- ❌ `EmailTemplateDefinitionSchema.locale` +
`EMAIL_TEMPLATE_FLOOR_LOCALE` — one
rung and "no fallback floor at all".
So the published declaration promised a **loud permanent refusal** on
exactly the
path where the runtime performs a **silent fill**. Fixed text-only,
in-fence: **inside
`packages/spec`** every floor claim is now scoped to "a call that NAMES
a locale", the
no-locale rung is stated beside it, and the ladder itself is stated in
one place only.
Carriers **outside** that package still state the old claim — they are
named under
Acceptance notes and deliberately not touched here.
Also corrected in the same fence, and declared rather than smuggled: the
`SendTemplateInput.template` TSDoc said the service "picks the
best-matching locale
row". Measured — there is no best match and no folding anywhere in the
resolver, so
that sentence described a behaviour this package has never had. Same
defect class,
same declared file surface, same gate family.
And `en`/`en-US` were called "different bundles" one paragraph after the
TSDoc
defines a bundle as rows sharing one `name`. They are different **rows**
of one
bundle; the rewritten sentence says so.
### Both sides of the generated projection, all four numbers
That `describe` projects into
`content/docs/references/system/email-template.mdx`.
A source-only fix would have left the old sentence on the reference
page:
| | old spelling | new spelling |
|---|---|---|
| SOURCE `packages/spec/src/system/email-template.zod.ts` | 0 | 1 |
| GENERATED `content/docs/references/system/email-template.mdx` | 0 | 1
|
Control: `BCP-47 locale` still returns 1 in the generated file, so the
zero above is
a real absence and not an unreadable path. Before `gen:docs` the
generated column read
`1 / 0` — the trap, caught and closed. `check:generated` now reports all
15 artifacts
up to date.
### It ships, measured on the built artifact
`packages/spec` publishes `dist` and `src/**/*.zod.ts`. In the built
tree: **16**
files carry the new describe string, **16** carry the new TSDoc scope,
**0** carry
the old spelling; positive control `BCP-47 locale` = 38 files. Hence the
changeset,
and hence `minor`.
## Half B — declared, pinned, and NOT flipped
`warnEmailTemplateLocaleFloor`'s **control flow is unchanged** — the
same bundles warn,
once each, and the warning stays advisory. What it gains is a
declaration of the two
shapes it does not examine, and pins that hold both.
⚠️ **Its emitted warning TEXT did change, and now names BOTH call
shapes** (5 concatenated
lines → 7): it says the bundle has no fallback floor *for a send that
names a locale*, and
adds that a send naming **no** locale does not fail but drops to that
bundle's lowest tag
and renders it silently. The changeset says the same. An earlier
revision of this body
called the guard unchanged *"byte-for-byte"* — true of the control flow,
false of the
string — so that phrase is gone.
Measured, and sharper than the card had it: **early return 1 decides
nothing on its
own.** `supportedLocales` is REQUIRED inside `i18n`, so its absent arm
is reachable
only via a stack with no `i18n` block; and with no supported set every
bundle's
`declared` list is empty, so early return 2 skips exactly the same
shapes. What
return 1 actually buys is not reading `.map` off `undefined` — deleting
it takes
`defineStack` down with a TypeError, which the new pin asserts.
**Blast radius, both directions, with a live control.** In-tree stacks
declaring
`emailTemplates`: two — `examples/app-showcase` (real i18n block parsed
from its own
config, real `allEmails` module) and
`packages/qa/dogfood/.../email-template-materialization-fixture.ts`
(whole stack). Both carry an `en-US` row, so both hit the floor check
before either
early return is reached.
- would newly warn if either early return were removed: **0**
- currently warning that would stop warning: **0** (no logic changed)
- positive control, the #17614 trap shape: **1 warning** — the harness
is not blind
The enforce-or-remove question (ADR-0049) is **not** answered here; it
is recorded in
the docblock and reported to the seat.
## Ablation — the pins bite, proven on disk
Every leg mutated the tree, proved the mutation by anchor count
**before** any result
was read, and restored by blob hash plus an empty `git diff HEAD`. Run
from the
committed state, under a `trap ... EXIT INT TERM` with absolute paths.
| leg | mutation (proven on disk) | result |
|---|---|---|
| 0 | none, clean `c50c54efae` | 14 passed |
| 1 | delete early return 1 (anchor 1 → 0, blob `7ba260b6` vs HEAD
`a9f45392`) | **3 failed** / 11 passed |
| 2 | delete early return 2 (anchor 1 → 0, blob `4c24ea22` vs HEAD
`a9f45392`) | **2 failed** / 12 passed |
| 3 | put the shipped unscoped `describe` back (`NAMES a locale` 2 → 1,
blob `0c068b72` vs HEAD `915c2e31`) | **1 failed** / 13 passed |
Restore verified each time: `RESTORED-OK … blob == HEAD`, final
`GIT_DIFF_HEAD_EMPTY=true`. No ablation artefact is left in the tree.
## Verification
- `pnpm --filter @objectstack/spec test` — **483 files, 13777 tests
passed**
- `pnpm --filter @objectstack/spec typecheck` — clean
- `pnpm --filter @objectstack/spec check:generated` — **all 15 artifacts
up to date**
- `eslint . --no-inline-config` over the **full** repo population:
**6796 files, 0
errors, 0 warnings, exit 0** — run at final commit `708595dc9c`, so no
narrowing
claim is needed
- Gate families derived from the real change set by
`scripts/pm/dispatch-gates.mjs`
(never a hand-written path list): **109 derived, 107 run green, 2 NOT
MEASURED**
(`check:dual-build-cjs-loads`, `check:type-check-debt` — both exit 3
`PREREQUISITE NOT MET`, an unbuilt workspace closure, which CI builds
fresh)
- One gate red, proven **not ours** by a two-leg control on a pristine
`origin/main`
worktree: `check:cross-package-test-inputs`. Leg A (all six of this PR's
paths, no
`packages/spec/dist/`) → **exit 0**. Leg B (zero of this PR's paths,
plus an empty
`packages/spec/dist/`) → **exit 1, identical finding**. It reds on the
presence of a
local spec build, not on this diff. Already filed as **#18440** (open,
2026-09-16T10:52Z) — ⛔ not re-filed here. Re-measured on this round's
worktree with no
`packages/spec/dist` present: **exit 0**, the same pre-build leg.
### This round (`642da7fda5`)
- `pnpm --filter @objectstack/spec exec vitest run` over the two edited
test files —
**2 files, 17 tests passed**, exit 0 (under the shared verify lock,
`VERDICT
command-exit 0`)
- `pnpm --filter @objectstack/spec typecheck` — exit 0
- Green, each exit code captured by redirect-then-`$?` and never through
a pipe:
`check:nul-bytes`, `check:changeset-no-major`,
`check:adr-0087-registration`,
`check:comment-mask-adoption`, `check:comment-mask-corpus`,
`check:spec-docblock-symbol-anchors`, `check:keyed-text-bounds`,
`check:test-source-alias`, `check:pm-widening-tells`,
`check:objectui-changeset`,
`check:pm-changeset-deadline-census`, `check:closing-keyword-parity`,
`check:cross-package-test-inputs`
- `check:empty-changeset` — **exit 1, RED BY DESIGN** (DELIBERATE
CORRECTION arm, above).
Its own `--self-test` passes, 159 assertions, so the instrument is
sound.
- The full CI farm on this head is left to CI and is **in progress**,
not assumed green.
## Acceptance notes
Noted, not filed — each with who would meet it:
- `packages/spec/src/system/email-template.form.ts` says nothing about
the floor, so the
Studio authoring path teaches none of the above. A gap, not an error.
Successor: the
next card touching the email-template authoring form.
- ⚠️ **Correction to an earlier revision of this note.**
`packages/platform-objects/src/audit/sys-email-template.object.ts:10-11`
does **not**
"say nothing about the floor". Measured on this tree, it carries the
false sentence
*"Resolved by `(name, locale)`; the EmailService picks the best-matching
locale for the
recipient, falling back to `en-US`"* — verbatim the third of the three
false declarations
that `packages/plugins/plugin-email/src/template-loader.ts:20-22`
already names. The same
claim lives at
`packages/services/service-messaging/src/objects/notification-template.object.ts:65`
("both resolve a template by best-matching locale") and at
`docs/qa/platform-checklist/areas/integration-system.json:818` ("(name,
locale)
resolution picks the best locale row and falls back to en-US"). There is
no best match
and no language-subtag folding anywhere in the resolver, so all three
are **false**, not
merely silent. **Out of this card's declared surface — named here for a
successor and
deliberately NOT fixed in this PR.** Successor: a platform-objects /
service-messaging
stale-carrier card, which the seat holds the finding for.
- `packages/metadata-core/src/item-key-discriminators.ts` quotes
`email-template.zod.ts` as saying the service "picks the best match for
the
recipient's locale" — measured 0 hits there (controls `i18n bundle` = 1,
`must stay equal` = 1). The sentence lives, in a variant, in
`contracts/email-service.ts`, and this PR corrects that variant, so the
quotation is
now doubly stale. Successor: the next card on email-template identity
keying.
- A cross-package pin holding the spec's published `describe` equal to
the runtime's
ladder would close this class mechanically, in the shape
`email-template-floor-locale-parity.pin.test.ts` already uses for the
constant.
Successor: none today — offered to the seat as a follow-up.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
|**locale**|`string`| optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY, with one retry rung: the literal `en-US`. No language-subtag folding, so `en` and `en-US` are different bundles and neither reaches the other. A bundle with no `en-US` row therefore has no fallback floor: any recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. |
47
+
|**locale**|`string`| optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY. A call that NAMES a locale gets exactly one retry rung, the literal `en-US`, with no language-subtag folding: `en` and `en-US` are different ROWS of one bundle and neither reaches the other, so a bundle with no `en-US` row has no fallback floor for those calls and every recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale is the other case and does not dead-letter: it drops to the bundle's lowest locale tag and renders that silently. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. |
48
48
|**subject**|`string`| ✅ | Subject template |
49
49
|**bodyHtml**|`string`| ✅ | HTML body template |
50
50
|**bodyText**|`string`| optional | Plain-text body template (auto-derived from HTML when omitted) |
0 commit comments