fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none - #17884
Conversation
Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…ail-template-locale-floor
Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d8d5d62486c97d2f434312f1df921d0b5d8cf9ec && git checkout d8d5d62486c97d2f434312f1df921d0b5d8cf9ec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2266438ce0ba039634f9dcec06db7be12c0d58ee 4feb4e245c3fc0eb7692f85bd30d282f3b4b72ca && git checkout -B drift-repro 2266438ce0ba039634f9dcec06db7be12c0d58ee && git merge --no-ff 4feb4e245c3fc0eb7692f85bd30d282f3b4b72ca
node scripts/docs-audit/affected-docs.mjs --json 2266438ce0ba039634f9dcec06db7be12c0d58ee
|
|
os-contract-review PASS WITH CONDITIONSThe fence held, both remedies are pinned and load-bearing, the accept set does not move, and the changeset grade is right. One real defect: the new constant's docblock publishes an invariant that nothing checks. That is fixable inside the fence, and it is the only thing standing between this and a clean PASS.
Conditions (each mechanically verifiable by the seat)
⛔ Neither condition asks for folding, a third rung, or any edit under Q1 — Which limb does clause ② fire on, and is
|
| side | parsed-ok | stored tags | warnings |
|---|---|---|---|
| remedy ② present | true |
["en","zh-CN","ja-JP","es-ES"] |
1 |
| remedy ② ablated | true |
["en","zh-CN","ja-JP","es-ES"] |
0 |
Only the warning count moves. Two lit controls, both of which came back the other way: (i) the same stack with a bogus top-level objectz key is refused (StackSchemaInvalidError, 1 issue) on both sides — so parsed-ok=true is a reading and not a dead capture; (ii) the identical bundle tagged en-US reports 0 floor warnings — so the diagnostic can stay silent. Aimed at: whether defineStack's accept/reject decision for this metadata shape differs before and after. It does not. The round's measurement is confirmed.
⇒ the seat's dispatch-time reasoning ("② narrows what passes silently") does not make clause ② fire. references/lanes/spec.md:19 is explicit the other way: 「放宽接受集或扩大公开面的卡,不论多小,即条款②;收窄仍是语义面,不触条款②」.
Published-surface limb: confirmed from the diff and the tree. EMAIL_TEMPLATE_FLOOR_LOCALE is a new exported const (packages/spec/src/system/email-template.zod.ts:41) newly registered at packages/spec/api-surface/system.json:203 and packages/spec/export-origins/system.json:197; and content/docs/references/system/email-template.mdx moves because a published describe changed.
Where I go further than either party: this is not a close judgment. references/contract-review.md states the carrier rule mechanically — 「机械地板 claim 时可查树:新导出符号或已发布载荷上的新键恒 yes,锁契约复审档复核」. A new exported symbol is yes by rule. So the declaration was over-determined: the seat reached the right answer via a limb that does not fire, and the round's correction is right but understates it — no measurement was ever needed for this carrier, only a look at the export list.
Q2 — Is the new export warranted, or surface growth for a constant?
Warranted. The missing pin, not the export, is the defect — and the pin fits inside the fence.
The constant earns its place: it is used 7 times, and it is genuinely one fact wearing two hats (the schema default at email-template.zod.ts:125 and the resolver's sole retry rung). Naming it once is the correct move.
But it is now the second published spelling of that fact. DEFAULT_TEMPLATE_LOCALE is not package-private: packages/plugins/plugin-email/src/email-service.ts:463 declares it and packages/plugins/plugin-email/src/index.ts:24 re-exports it. Two published constants, two packages, one settled value, no pin — and email-template.zod.ts:38 publishes the sentence "The two must stay equal".
⭐ This is a new drift seam, and it is precisely the defect class this package already wrote a pin to catch. packages/spec/src/identity/position-delegatable-enforcer.pin.test.ts exists because an authoring-surface docblock named an enforcer that had never been written; its own header states the hazard in general terms: "prose costs nothing to add and no compiler checks it." A published "must stay equal" with nothing checking it is the same shape.
Can a pin be added within the fence? Yes — measured, not assumed. packages/spec does not depend on @objectstack/plugin-email (its dependencies are pg-connection-string and zod only), so an import is impossible. But packages/spec/vitest.config.ts defines a second project, repo, expressly for tests that read outside the package, and the precedent above is one of its 31 members — a packages/spec pin that reads another package's source to hold a cross-package claim honest. A parity pin of that shape edits nothing under packages/plugins/plugin-email, adds no rung, and does not touch EmailService.resolveAndRenderTemplate. The fence does not reach it. Hence C1(a); C1(b) is the equally acceptable retreat.
Q3 — Is the advisory console.warn the right instrument?
Yes — the claimed precedent is real, and the ruling's own words are comparative, so this meets them.
Precedent verified in the tree, not accepted from the report: warnUnknownAuthoringKeys is defined at packages/spec/src/stack.zod.ts:2652 and called at 2764; it emits console.warn and is warn-once via the module-level warnedUnknownAuthoringKeys set. A second precedent sits beside it — warnConversionNotice, same seam, same warn-once shape, docblock reading "Advisory and warn-once". The new warnEmailTemplateLocaleFloor (stack.zod.ts:2706, called at 2843) matches on seam (defineStack), instrument (console.warn) and warn-once (warnedEmailTemplateFloors).
One honest difference, which the PR itself documents rather than glosses: warnUnknownAuthoringKeys runs pre-parse ("because the parse is what eats the key"); the new one runs post-parse, because locale carries a default and a pre-parse read would report a floor the row actually has. That is a correct reason, and it is written down at the call site.
Triage's standard for ② was "is reported, the way other declared-but-unreachable metadata is reported today" — a comparative standard whose comparator is exactly warnUnknownAuthoringKeys. This PR meets it as written. And triage's "the durable half" phrase is doing a different job: it contrasts ② with ① ("① alone leaves the trap armed for anyone who does not read"), not warn with refuse.
console.warn at defineStack is heard only by whoever runs the build and reads stdout — and the card's own premise is that this trap survives because people do not read. The remedy's durability is therefore bounded by the same weakness it diagnoses. The natural stronger rung that is still inside the fence is an os lint / os validate rule, which is a separate card, not this one.
Q4 — Does this belong on the MANUAL FLOOR?
No. It belongs on this tier — which is where it is. ⛔ No 维护者速读 is owed.
I read the rule in its own section before citing it, as instructed. SKILL.md:391 「代裁人工地板(恒交维护者)」 and SKILL.md:397 「机械边界测试:改动扩大接受集或公开面 ⇒ 人工」 sit inside the 代裁 block (roughly SKILL.md:391-411), which governs when the PM may self-adjudicate a card sitting in the decision box. SKILL.md:401 defines the 代裁 action itself as flipping needs-user-decision into a working state. #17614 was never in the decision box — triage graded it pm:queue, ruled both remedies in, and wrote 「⛔ Neither needs a maintainer」. The 代裁 floor is therefore not reached on its own terms, and the boundary test's 「⇒ 人工」 tail does not transplant out of it.
The rule that actually governs this card is 强制条款② at SKILL.md:514: 「凡放宽接受集或扩大公开面的卡默认判断档施工、契约复审档复核」 — judgment tier builds, contract-review tier reviews. SKILL.md:515 says clause ②'s criterion is the 代裁 mechanical boundary test, borrowed as a criterion: that is exactly why its 「⇒ 人工」 does not come with it. Within clause ②, the same test routes to 契约复审档复核. references/contract-review.md closes it for this precise shape: a new exported symbol is 恒 yes, 「锁契约复审档复核」 — it locks review at this tier and names no maintainer.
On the substance, which is what you asked me to answer on:
- A new published export and a changed published
describe— both real, both clause-② carriers, both reviewed here. - No accept set moves (Q1, measured with lit controls). No ADR. No breaking or hard-to-revert action — the whole of remedy ② is one
console.warn, revertible by deleting one line. No security or permission boundary. No gate weakening. No new runtime dependency. - Not 功能新增 in the product sense: no new capability is offered to an app author. A constant is named and a warning is emitted about metadata that already validated and still validates.
- The 人工地板 item nearest to biting is 「协议/公开契约变化」. Read in its 代裁 context this is about changing a protocol contract. This PR changes no contract semantics — it documents an existing one and names the floor tag the resolver has always used. The
describenow says whatEmailServicehas done since [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881. - The
protocol:systemcarrier adds nothing:protocol:systemappears zero times inAGENTS.mdand zero times anywhere under.claude/. It is a surface-routing label with no gate attached. - Escalating would also sit badly with 置信门 ③ 「不推翻既有维护者裁决」: triage's standing ruling on this card explicitly says neither remedy needs a maintainer.
Q5 — Is anything owed that is missing?
Changeset — correct, and correct for the right reason. .changeset/email-template-locale-floor.md, minor on @objectstack/spec. references/lanes/spec.md:18 makes a pure text-surface change 恒 patch, and the text/semantic split is 「改动前过校验的输入改动后同判 ⇒ 文本面」 — which is exactly what I measured in Q1. So the describe and the warning alone would have been patch. It is the new export that makes minor right. The PR body reaches minor citing both halves; only the second half load-bears, but the grade lands correct.
Generated artifacts — re-taken, and one of my own readings was VOID before I fixed it. My first check:generated run exited 1 on check:api-surface, but the gate refused rather than judged: "packages/spec/dist holds no .d.ts declarations — the package is not built... the removals above are phantoms." Per discipline that is NOT MEASURED, never a red. I built (pnpm --filter @objectstack/spec build, exit 0, 34/34 declaration files) and re-ran: All 15 generated artifacts are up to date, command-exit 0, with check:api-surface, check:export-origins and check:docs — exactly the three the round reported regenerating — each ✓. Confirmed.
Pin file re-run and ablated myself (packages/spec/src/stack-email-template-locale-floor.test.ts, 10 tests). Every mutation was proved to land by occurrence count and git hash-object, and every restore proved by hash against the HEAD blob:
| mutation | occurrence proof | blob | result |
|---|---|---|---|
MUT-A — delete the diagnostic's call site (stack.zod.ts:2843), i.e. remedy ② is gone |
1 to 0 | 54f52c7c to 1ecd340c |
RED, 4 failed / 6 passed |
MUT-C — the describe loses TEMPLATE_NOT_FOUND, i.e. remedy ① is gutted |
2 to 1 | c800b79e to 30faa60c |
RED, 1 failed / 9 passed |
| restored (both files) | back to 1 / 2 | 54f52c7c, c800b79e MATCH |
GREEN, 10 passed |
Both halves of the remedy are load-bearing. MUT-A and MUT-C independently reproduce the round's MUT-1 and MUT-2 failure counts exactly.
The round's GREEN ablation (MUT-3) — the correction is honest, and I confirmed the mechanism. I ablated the thing the corrected comment actually claims: the reader's missing-locale fallback at stack.zod.ts:2719, changing : EMAIL_TEMPLATE_FLOOR_LOCALE to a sentinel 'xx-XX' (sentinel count 1, blob 54f52c7c to da897323). GREEN, 10 passed. That branch is never exercised on the post-parse path, because post-parse locale is always a string. So the corrected comment — "the reader mirrors the schema default for a missing key, so the two agree and the case is silent either way; the pin is the behaviour, not the call site" — is exactly what the tree does. The round reported a falsifying result against its own written claim and rewrote the claim to match the measurement. That is the right handling.
Does the case still pin something real? Yes, and honestly labelled. It asserts an omitted locale is never reported and that it parses to the floor tag — real behaviour, and it is one of the 6 that survive MUT-A, i.e. it is a negative-space pin, which is what its comment now says it is. Residue worth knowing: the defensive fallback at :2719 is itself unpinned, because nothing calls the reader pre-parse. Acceptable — it only matters if someone later moves the call, and the docblock says so.
The declared boundary — a boundary of the ruling, as the round claims. Triage's option 2 is worded 「a bundle carrying rows for a stack's supportedLocales but none tagged en-US」. A stack with no i18n block has no supportedLocales, so the condition cannot be evaluated; stack.zod.ts:2707-2708 implements the wording verbatim. The round is right that this is the ruling's edge, not a defect of the round — widening it here would have exceeded the ruling. stack.zod.ts:2728, and it is C2 above. Both are residual holes in the remedy while being faithful to the ruling — which is a successor card, not a revision of this PR.
Corpus census — verified, and stronger than the round claimed. examples/app-showcase/src/system/emails/index.ts:37 tags en-US, so it stays silent. Its header is live corroboration that this trap has already bitten once in this repo: "this row used to say en, which made sendTemplate({ locale: 'en-US' }) fail with TEMPLATE_NOT_FOUND." That is the "one careful reading" triage named, and it is a past incident rather than a hypothetical.
Fence — held. The merge base is unavailable in this shallow clone, so I measured the two-dot base..head file list, which is a strict superset of the PR's three-dot diff: 0 paths under packages/plugins/plugin-email. Lit control: feeding the same grep one synthetic packages/plugins/plugin-email/src/email-service.ts line returns 1 — the probe fires when a match exists. Independently, the PR's own file list is 7 files, none under that path. And the ladder at packages/plugins/plugin-email/src/email-service.ts:463 and :1317-1324 is byte-for-byte what the card quotes: exact (name, locale), one rung at the literal en-US, no folding.
What I did NOT measure
test:repo(projectrepo, 30 files / 520 tests) — NOT re-taken. I re-took only thelocalproject.local— re-taken, twice, and the numbers moved between them for a reason worth recording. On an unbuilt tree: 472 passed + 1 skipped (473 files), 13442 passed + 1 skipped (13443).⚠️ A skipped test is not a passed test, so I chased it:packages/spec/scripts/root-entry-type-nameability.pin.test.ts, which self-skips when spec'sdistdeclarations are absent. After building, the same command gives 473 passed (473), 13444 passed (13444), exit 0 — reproducing the round's figures exactly. No misreport by the round; its numbers were taken on a built tree, my first reading on an unbuilt one.typecheck,eslint, and the 24 gate families — NOT re-taken. I have no independent reading on any of them; the round's exit codes stand unverified by me.- The resolver delivery probes — NOT re-run. I did not boot
EmailServiceor callsendTemplate, so I have no first-handTEMPLATE_NOT_FOUND/DELIVEREDreading. I verified the ladder by source read only. The round's probe B and its lit control are therefore adopted, not reproduced. - The 83 gate families declared to CI — not run here, by either of us.
- The merge-base three-dot diff — not computable in this shallow clone; see the superset argument above.
- Tier self-verification is not mine to make.
references/contract-review.mdis explicit that 「传参只是配置 ⛔ 不作达档读数」 — a passed model parameter is configuration, not a tier reading — and that the採信 step is the parent's transcript check of the harness-stampedmodelfield. I assert no tier reading about myself; the dispatching seat owes that verification before acting on this verdict.
Tier statement
This is an in-seat at-tier review. The adjudicating subagent inherits the dispatching seat's session id (session_01MkQhmuuJAVDjmeWNixwDDH), so it is ⛔ NOT an independent second seat — it is reported as SELF-REVIEW per references/contract-review.md 「两者同 session ⇒ 报 SELF-REVIEW,⛔ 不作独立复核」. It was dispatched with an explicit model parameter. ⛔ I did not flip this PR ready, enqueue it, arm auto-merge, submit a GitHub approving review, or strip needs:contract-review; ⛔ I wrote no code and touched no repository file. If C1 is judged a genuine contract fork rather than a cleanup, references/contract-review.md calls for a context-isolated reviewer for a true second opinion — that route remains open and is not foreclosed by this verdict.
Generated by Claude Code
…project pin C1 from contract review: `EMAIL_TEMPLATE_FLOOR_LOCALE`'s docblock publishes "the two must stay equal" about plugin-email's `DEFAULT_TEMPLATE_LOCALE`, and nothing held them equal. The pin reads that package's source as text (the `repo` vitest project's purpose) and is registered in vitest.repo-tests.json, so it edits nothing under packages/plugins/plugin-email. Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Contract-review gate CLEARED — the verdict, the conditions, and the readings that close themPosted by the The verdict: C1 — «back the parity claim or stop publishing it» — CLOSED by (a), the pin. Seat-verified on the PR head
C2 — «name the second silent-but-floorless case» — CLOSED as a record, ⛔ no code change asked for or made. The PR body now carries a dedicated section naming Clause-② stays ⇒ Gate cleared on the verdict above with both conditions closed. Landing proceeds through the seat's four pre-checks; ⛔ the PR is not enqueued until every one of them reads green on the FINAL diff. Generated by Claude Code |
…a locale Text-only remediation on the contract-review record for this PR. No logic line moves: both .ts files reprint byte-identical through the TypeScript printer with removeComments (stripped sha256 equal before and after), and a dark control on a pair that really does move code reads DIFFERENT, so the instrument is not blind. - .changeset/email-template-locale-floor.md (pending and unreleased, from #17884): "retries exactly one rung - the literal en-US" and "the resolver's sole retry rung" were unscoped, and would have compiled verbatim into the published packages/spec CHANGELOG.md beside this PR's correction of them -- the erratum-in-a-later-entry form AGENTS.md 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 too. - stack-email-template-locale-floor.test.ts: the header sentence now scopes the one-rung claim and names the no-locale case; the title line's bare "no fallback floor" is scoped the same way. - email-template-floor-locale-parity.pin.test.ts: "its single retry rung" now names both rungs and which call shape reaches each. - .changeset/18056-email-template-locale-rungs.md: states that the guard's emitted warning TEXT changed and now names both call shapes, keeping "control flow is unchanged" and dropping any byte-for-byte claim. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <[email protected]>
Fixes #17614
Clause-②: yes
Triage ruled BOTH remedies in, with an order (comment 5642916137): 「do both, option 1 first.」 Both land here.
The trap, reproduced on this branch's own head before anything changed
An app declares
i18n: { defaultLocale: 'en', supportedLocales: ['en','zh-CN','ja-JP','es-ES'] }and authors its EnglishemailTemplatesrow aslocale: 'en'— the consistent thing. Measured on an unmodified tree:defineStackon that stackparsed-ok=true warnings=0, stored locales["en","zh-CN","ja-JP","es-ES"]sendTemplate({ locale: 'de-DE' })TEMPLATE_NOT_FOUND: acme.welcome (locale=de-DE)sendTemplate({ locale: 'en-US' })TEMPLATE_NOT_FOUND: acme.welcome (locale=en-US)en-USsubject=[en-US] HiThe control is aimed at the resolver ladder itself: it proves the probe can come back "delivered", so the two
TEMPLATE_NOT_FOUNDreadings are readings and not a broken harness. A second lit control covers thedefineStackhalf — anobjectztypo on the same stack is refused, sowarnings=0is a measurement rather than a dead capture.TEMPLATE_NOT_FOUNDclassifies permanent, so each of those dead-letters with no retry.sys_user.localeis user-editable free-text BCP-47 and is not constrained tosupportedLocales, so the locales that can reach the lookup are not the ones the author enumerated.Premise re-read on today's tree, not on the card
The card measured against installed 17.4.0. Re-read at this branch's base: the ladder in
packages/plugins/plugin-email/src/email-service.tsis byte-for-byte what the card quotes — exact(name, locale), one retry rung at the literalDEFAULT_TEMPLATE_LOCALE = 'en-US', no folding — andEmailTemplateDefinitionSchema.localewas stillz.string().default('en-US').describe('BCP-47 locale (e.g. en-US, zh-CN)'). The premise holds; the trap is open. Its TSDoc was also actively misleading — "the service picks the best match for the recipient's locale" reads as folding, which does not exist on that path.① Say it where the author is standing
EmailTemplateDefinitionSchema.locale'sdescribeand TSDoc now state the exact match, the single literalen-USrung, the absence of language-subtag folding, that a bundle with noen-USrow has no floor, and that the stack's own declared default locale is the wrong tag whenever it is not spelleden-US. Thedescribeis the published authoring surface — it lands incontent/docs/references/system/email-template.mdx, regenerated here.New exported
EMAIL_TEMPLATE_FLOOR_LOCALEnames the tag once, in the one place that is both the schema default and the resolver's sole retry rung.② The author-time diagnostic
defineStacknow reports anemailTemplatesbundle that carries rows for the stack's owni18n.supportedLocalesbut none taggeden-US. Advisoryconsole.warn, warn-once per bundle, post-parse — the same seam and posture as the existingwarnUnknownAuthoringKeysreporter.⛔ The fence held
Language-subtag folding was not added, no third rung was added, and
EmailService.resolveAndRenderTemplateis not touched — the diff contains no file underpackages/plugins/plugin-email. The ladder's shape stays as #13881 settled it; the remedy this points at is the bundle.Clause-② self-check —
yes, and the rationale is correctedyesis right, and it is a mechanical call, not a measurement. The carrier that fires is the new exported symbolEMAIL_TEMPLATE_FLOOR_LOCALE— a new export on a published surface is alwaysyes, decided by looking at the export list. It is registered atpackages/spec/api-surface/system.jsonandpackages/spec/export-origins/system.json; a changed publisheddescribemovescontent/docs/references/system/email-template.mdxalongside it.yeson the ground that remedy ② narrows what passes silently, and that rule does not exist — the lane text says the opposite, that narrowing is still a semantic surface and does not trip clause ②. The declaration was over-determined: the right answer reached through a limb that does not fire.The accept-set measurement below stands and is not the basis for the
yes; it is reported because it is true and reviewable. The final diff moves no accept set: same probe, same tree, before and after,parsed-ok=trueon both sides and the returned stack carries the identical tags["en","zh-CN","ja-JP","es-ES"]; only the warning count goes 0 to 1. No Zod refinement added, no schema key added or removed, no closed set narrowed.Carrier state: PR and card both carry
needs:contract-review;scripts/pm/check-clause2-carriers.mjs --pair 17884exits 0, both carriers agree. Draft, not enqueued, auto-merge not armed — the seat lands it.Contract review — C1 resolved, option (a)
Review verdict: PASS WITH CONDITIONS (comment 5647795889). C1 said the new constant's docblock publishes an invariant — that
@objectstack/plugin-email'sDEFAULT_TEMPLATE_LOCALEandEMAIL_TEMPLATE_FLOOR_LOCALE"must stay equal" — with nothing holding them equal, the same shape as this package's existingposition-delegatable-enforcer.pin.test.tsand its own words, "prose costs nothing to add and no compiler checks it."Chose (a), add the pin, over (b), delete the sentence. The sentence is true and useful: both constants are published (
plugin-emailre-exportsDEFAULT_TEMPLATE_LOCALEfrom itsindex.ts), they are genuinely one settled value in two packages, and an author or a future round needs to know they are coupled. Deleting it would buy honesty by removing information, leaving the drift seam just as real and now undocumented. (a) keeps the claim and makes it checkable, and it costs nothing this PR was not already allowed to spend — so (b) is the retreat, not the equal.packages/spec/src/system/email-template-floor-locale-parity.pin.test.tsreadspackages/plugins/plugin-email/src/email-service.tsas text and asserts itsDEFAULT_TEMPLATE_LOCALEliteral equalsEMAIL_TEMPLATE_FLOOR_LOCALE.packages/speccannot importplugin-email(its dependencies arezodandpg-connection-string) and must not start to; therepovitest project exists for exactly this, and the path is registered inpackages/spec/vitest.repo-tests.json. A missing or renamed declaration fails loudly rather than passing vacuously.⛔ The fence is still intact. The delivered diff contains no file under
packages/plugins/plugin-email; no rung was added, nothing folds, andEmailService.resolveAndRenderTemplateis untouched. The pin only reads that package.Successor-card seed (C2) — the second silent, floorless case
packages/spec/src/stack.zod.ts, thedeclared.length === 0branch ofwarnEmailTemplateLocaleFloor. A stack that does declarei18n.supportedLocalesbut whose bundle tags none of them is equally floorless and equally silent —supportedLocales: ['en']with a lonefr-CArow. This PR's own test pins that green ("silent when no authored tag is one this stack claims to support"), so the gap is deliberate and recorded rather than accidental. ⛔ Not fixed here: widening the condition is a second decision about who gets warned, and it is left as the seed of a successor card together with the no-i18n-block case already noted below.A third, named by the review and recorded here for the same successor: a
console.warnatdefineStackis heard only by whoever runs the build and reads stdout, while this card's own premise is that the trap survives because people do not read. The stronger rung that stays inside the fence is anos lint/os validaterule — a separate card, not this one.Tests, and the ablation that proves each one could fail
New:
packages/spec/src/stack-email-template-locale-floor.test.ts(10 tests). Every case asserts the parse still succeeds and the stack returns unchanged, so the pins are on the advisory posture too.Proven by on-disk mutation, each with its injected-text count verified before the run and the restore verified by
git hash-objectagainst the HEAD blob:describelosesTEMPLATE_NOT_FOUNDMUT-3 falsified a claim I had written into the test: the reader mirrors the schema default for a missing
locale, so pre-parse and post-parse agree and the call site is not what that case pins. The comment and the function's rationale were corrected to say what was actually measured, rather than the reverse.C1 parity pin — ablated, because an un-ablated pin is not a pin
Mutated
plugin-email's literal on disk fromen-UStoen-GB(restored immediately; the delivered diff does not touch that file):0198f7acto108eb1b8)AssertionError: expected 'en-GB' to be 'en-US', 1 failed / 2 passed0198f7ac, MATCHES the HEAD blob,git diff HEADempty; re-run exit 0, 3 passedpnpm check:cross-package-test-inputsexits 0 with the new escape declared. That zero is a reading, not a dead gate: unregistering the path fromvitest.repo-tests.jsonreds it (exit 1) naming this exact file, and it was then re-registered.Commands and exit codes
Both
packages/specvitest projects were run and their exit codes captured separately (the wrapper's own verdict line covers only the last part, so each was captured inside the run):pnpm --filter @objectstack/spec test(projectlocal)LOCAL_EXIT=0— 473 files / 13444 tests passedpnpm --filter @objectstack/spec test:repo(projectrepo)REPO_EXIT=0— 30 files / 520 tests passedpnpm --filter @objectstack/spec typecheckTC_EXIT=0pnpm --filter @objectstack/spec check:generated--fixeslint . --no-inline-config --format jsond81bcb4ae224 of the 107 gate families
scripts/pm/dispatch-gates.mjsderives for this change set were run locally, all exit 0; the remaining 83 are declared to CI rather than run on the shared box. This is a declared narrowing, not a skipped one.Changeset
.changeset/email-template-locale-floor.md,minoron@objectstack/spec. One is owed and is not skippable: ① changes a publisheddescribe(it movescontent/docs/references/) and the diff adds a published export.Corpus census
No in-repo stack begins warning. The one example app that declares both halves —
examples/app-showcase(defaultLocale: 'en',supportedLocales: ['en','zh-CN']) — already tags its bundleen-US, and its own header comment explains why. That is the "one careful reading" triage named as the only thing standing between this trap and a live outage; it is now the schema's job instead.Acceptance notes (out of scope, noted, not filed)
@objectstack/plugin-emailspells the floor as its ownDEFAULT_TEMPLATE_LOCALE; this PR adds a second spelling in spec rather than making the two one constant, because collapsing them means editing the ladder's own file, which the fence puts out of reach for this round. The new constant's docblock states the dependency direction. No parity pin is added here. Successor: whoever next opensemail-service.tsunder a ruling that lifts the fence.i18n.supportedLocalesto be declared. A stack that declares noi18nblock and authors a loneenrow is equally floorless and stays silent. Named as a deliberate boundary, not widened here.Generated by Claude Code