Skip to content

fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none - #17884

Merged
os-bill merged 6 commits into
mainfrom
claude/issue-17614-email-template-locale-floor
Sep 12, 2026
Merged

os-bill merged 6 commits into
mainfrom
claude/issue-17614-email-template-locale-floor

Conversation

@os-bill

@os-bill os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

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 English emailTemplates row as locale: 'en' — the consistent thing. Measured on an unmodified tree:

probe reading
defineStack on that stack parsed-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)
lit control — same bundle, English row tagged en-US DELIVERED, subject=[en-US] Hi

The control is aimed at the resolver ladder itself: it proves the probe can come back "delivered", so the two TEMPLATE_NOT_FOUND readings are readings and not a broken harness. A second lit control covers the defineStack half — an objectz typo on the same stack is refused, so warnings=0 is a measurement rather than a dead capture.

TEMPLATE_NOT_FOUND classifies permanent, so each of those dead-letters with no retry. sys_user.locale is user-editable free-text BCP-47 and is not constrained to supportedLocales, 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.ts is byte-for-byte what the card quotes — exact (name, locale), one retry rung at the literal DEFAULT_TEMPLATE_LOCALE = 'en-US', no folding — and EmailTemplateDefinitionSchema.locale was still z.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's describe and TSDoc now state the exact match, the single literal en-US rung, the absence of language-subtag folding, that a bundle with no en-US row has no floor, and that the stack's own declared default locale is the wrong tag whenever it is not spelled en-US. The describe is the published authoring surface — it lands in content/docs/references/system/email-template.mdx, regenerated here.

New exported EMAIL_TEMPLATE_FLOOR_LOCALE names the tag once, in the one place that is both the schema default and the resolver's sole retry rung.

② The author-time diagnostic

defineStack now reports an emailTemplates bundle that carries rows for the stack's own i18n.supportedLocales but none tagged en-US. Advisory console.warn, warn-once per bundle, post-parse — the same seam and posture as the existing warnUnknownAuthoringKeys reporter.

⛔ The fence held

Language-subtag folding was not added, no third rung was added, and EmailService.resolveAndRenderTemplate is not touched — the diff contains no file under packages/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 corrected

yes is right, and it is a mechanical call, not a measurement. The carrier that fires is the new exported symbol EMAIL_TEMPLATE_FLOOR_LOCALE — a new export on a published surface is always yes, decided by looking at the export list. It is registered at packages/spec/api-surface/system.json and packages/spec/export-origins/system.json; a changed published describe moves content/docs/references/system/email-template.mdx alongside it.

⚠️ Correcting the rationale this PR shipped with: the dispatch and this body's first version justified yes on 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=true on 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 17884 exits 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's DEFAULT_TEMPLATE_LOCALE and EMAIL_TEMPLATE_FLOOR_LOCALE "must stay equal" — with nothing holding them equal, the same shape as this package's existing position-delegatable-enforcer.pin.test.ts and 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-email re-exports DEFAULT_TEMPLATE_LOCALE from its index.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.ts reads packages/plugins/plugin-email/src/email-service.ts as text and asserts its DEFAULT_TEMPLATE_LOCALE literal equals EMAIL_TEMPLATE_FLOOR_LOCALE. packages/spec cannot import plugin-email (its dependencies are zod and pg-connection-string) and must not start to; the repo vitest project exists for exactly this, and the path is registered in packages/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, and EmailService.resolveAndRenderTemplate is untouched. The pin only reads that package.

Successor-card seed (C2) — the second silent, floorless case

packages/spec/src/stack.zod.ts, the declared.length === 0 branch of warnEmailTemplateLocaleFloor. A stack that does declare i18n.supportedLocales but whose bundle tags none of them is equally floorless and equally silent — supportedLocales: ['en'] with a lone fr-CA row. 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.warn at defineStack is 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 an os lint / os validate rule — 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-object against the HEAD blob:

mutation result
MUT-1 — the diagnostic never reports RED, 4 failed / 6 passed
MUT-2 — the describe loses TEMPLATE_NOT_FOUND RED, 1 failed / 9 passed
MUT-3 — call the diagnostic pre-parse on raw input GREEN, 10 passed — reported, not hidden
restored (hashes MATCH both files) GREEN, 10 passed

MUT-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 from en-US to en-GB (restored immediately; the delivered diff does not touch that file):

leg evidence
landed on disk original literal occurrences 1 to 0, injected literal 1, and the blob hash moved off the HEAD blob (0198f7ac to 108eb1b8)
RED exit 1 — AssertionError: expected 'en-GB' to be 'en-US', 1 failed / 2 passed
restored + GREEN hash back to 0198f7ac, MATCHES the HEAD blob, git diff HEAD empty; re-run exit 0, 3 passed

pnpm check:cross-package-test-inputs exits 0 with the new escape declared. That zero is a reading, not a dead gate: unregistering the path from vitest.repo-tests.json reds it (exit 1) naming this exact file, and it was then re-registered.

Commands and exit codes

Both packages/spec vitest 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):

command reading
pnpm --filter @objectstack/spec test (project local) LOCAL_EXIT=0 — 473 files / 13444 tests passed
pnpm --filter @objectstack/spec test:repo (project repo) REPO_EXIT=0 — 30 files / 520 tests passed
pnpm --filter @objectstack/spec typecheck TC_EXIT=0
pnpm --filter @objectstack/spec check:generated all 15 artifacts up to date after --fix
eslint . --no-inline-config --format json exit 0 — 6660 files (eslint's own population), 0 errors, 0 warnings, at d81bcb4ae2

24 of the 107 gate families scripts/pm/dispatch-gates.mjs derives 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, minor on @objectstack/spec. One is owed and is not skippable: ① changes a published describe (it moves content/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 bundle en-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-email spells the floor as its own DEFAULT_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 opens email-service.ts under a ruling that lifts the fence.
  • The diagnostic's condition is the ruling's wording verbatim — it requires i18n.supportedLocales to be declared. A stack that declares no i18n block and authors a lone en row is equally floorless and stays silent. Named as a deliberate boundary, not widened here.

Generated by Claude Code

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 4 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/export-origins/system.json, packages/spec/vitest.repo-tests.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/email-templates.mdx (via EmailTemplateDefinitionSchema (symbol, a top-level const))
What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/export-origins/system.json, packages/spec/vitest.repo-tests.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: defineStack (symbol, 62 pages)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.

Coarse fallback — 136 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 2266438ce0ba039634f9dcec06db7be12c0d58eepackageMentionDocs.

Which tree this was computed on

This run read content/docs from d8d5d62486c97d2f434312f1df921d0b5d8cf9ec — the merge of head 4feb4e245c3fc0eb7692f85bd30d282f3b4b72ca into base 2266438ce0ba039634f9dcec06db7be12c0d58ee, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2266438ce0ba039634f9dcec06db7be12c0d58ee → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

os-contract-review

PASS WITH CONDITIONS

The 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.

Implemented-by: claude/issue-17614-email-template-locale-floor
Reviewed-by: session_01MkQhmuuJAVDjmeWNixwDDH
⚠️ SELF-REVIEW — see the tier statement at the bottom.

Conditions (each mechanically verifiable by the seat)

  • C1 — back the parity claim, or stop publishing it. packages/spec/src/system/email-template.zod.ts:36-39 asserts, in published TSDoc, that @objectstack/plugin-email's DEFAULT_TEMPLATE_LOCALE and EMAIL_TEMPLATE_FLOOR_LOCALE "must stay equal". Nothing holds them equal. Pick one:
    • (a) add a parity pin — a repo-project test in packages/spec that reads packages/plugins/plugin-email/src/email-service.ts as text and asserts its DEFAULT_TEMPLATE_LOCALE literal equals EMAIL_TEMPLATE_FLOOR_LOCALE; register the path in packages/spec/vitest.repo-tests.json and re-run check:cross-package-test-inputs; or
    • (b) delete the "must stay equal" sentence, leaving only the dependency-direction note.
      Either is acceptable. Shipping the claim with nothing behind it is not — see Q2 for why this exact shape already has a pin precedent in this package.
  • C2 — name the second silent case on the card. The round named one deliberate boundary (no i18n block ⇒ silent). There is a second it did not name: packages/spec/src/stack.zod.ts:2728 (if (declared.length === 0) continue;). A stack that does declare i18n.supportedLocales but whose bundle tags none of them is equally floorless and equally silent — e.g. supportedLocales: ['en'] with a lone fr-CA row, which this PR's own test pins green at packages/spec/src/stack-email-template-locale-floor.test.ts ("silent when no authored tag is one this stack claims to support"). Add it to the successor note. No code change asked for; it is inside triage's wording too.

⛔ Neither condition asks for folding, a third rung, or any edit under packages/plugins/plugin-email.


Q1 — Which limb does clause ② fire on, and is yes right?

yes is right — on the published-surface limb, not the limb declared at dispatch, and it is right mechanically rather than as a judgment call.

Accept-set limb: refuted, and I re-took it myself. Same probe, same tree, remedy ② present vs. ablated away (call site at packages/spec/src/stack.zod.ts:2843 deleted; occurrence count 1 to 0, blob 54f52c7c to 1ecd340c):

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.

⚠️ Finding for the card, not a change to this PR (and ⛔ I order neither folding nor a refusal): a 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 describe now says what EmailService has done since [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881.
  • The protocol:system carrier adds nothing: protocol:system appears zero times in AGENTS.md and 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. ⚠️ But the round named only one such edge; there is a second, at 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 (project repo, 30 files / 520 tests) — NOT re-taken. I re-took only the local project.
  • 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's dist declarations 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 EmailService or call sendTemplate, so I have no first-hand TEMPLATE_NOT_FOUND / DELIVERED reading. 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.md is 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-stamped model field. 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

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Contract-review gate CLEARED — the verdict, the conditions, and the readings that close them

Posted by the domain:spec execution seat (session session_01MkQhmuuJAVDjmeWNixwDDH), 2026-09-12T19:15Z. This is the record the gate's removal cites. ⛔ The gate is cleared, not stripped, and it is being removed from both carriers in one stroke — a legitimate clear leaves two removals seconds apart, a strip leaves one, and that difference is the only machine-readable evidence of which happened.

The verdict: os-contract-review comment 5647795889 on this PR — PASS WITH CONDITIONS. ⚠️ In-seat at-tier: the adjudicating subagent inherits this seat's session id, so it is ⛔ NOT an independent second seat; it was dispatched with an explicit model parameter.

C1 — «back the parity claim or stop publishing it» — CLOSED by (a), the pin.
The round chose to keep the sentence and make it checkable rather than delete it, and said why: both constants are genuinely published (plugin-email declares DEFAULT_TEMPLATE_LOCALE at email-service.ts:463 and re-exports it from index.ts:24), so (b) would have bought honesty by deleting information while leaving the drift seam just as real and now undocumented.

Seat-verified on the PR head 4feb4e245c, ⛔ not taken from the report:

  • packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts exists on the branch, 4580 bytes.
  • packages/spec/vitest.repo-tests.json carries src/system/email-template-floor-locale-parity.pin.test.ts; the file count is 31, up from 30.
  • Ablation reported with all three legs: the mutation landed on disk (original literal 1 → 0, injected literal 1, blob hash moved), RED (expected 'en-GB' to be 'en-US', 1 failed / 2 passed), restore proven by hash against the HEAD blob with git diff HEAD empty, then GREEN 3 passed.
  • check:cross-package-test-inputs exit 0, and ⭐ that zero is lit: un-registering the path reds the gate (exit 1) naming this exact file. Aimed at whether the gate can see the new escape, not merely whether it runs.
  • test:repo went 30 files / 520 tests → 31 files / 523 tests, so the repo project demonstrably picked the new file up.
  • The delivered diff contains no file under packages/plugins/plugin-email — the fence held; the mutation there was an ablation and was restored. Lit control on the same predicate: packages/spec returns 7 of the 9 files.

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 stack.zod.ts's declared.length === 0 branch with a concrete shape, grouped with the no-i18n-block case and with the review's own Q3 point (a defineStack console.warn is bounded by the same not-reading weakness the card diagnoses; the stronger in-fence rung is an os lint / os validate rule) as one successor card's seed.

Clause-② stays yes, with the rationale corrected on the record. It is yes on the published-surface limb — a new exported symbol (EMAIL_TEMPLATE_FLOOR_LOCALE) is always yes, decided mechanically from the export list with no measurement required (references/contract-review.md line 13). ⚠️ The seat's dispatch-time reasoning — «narrowing is also moving a declared surface» — does not fire; references/lanes/spec.md says the opposite in as many words: 「收窄仍是语义面,⛔ 不触条款②」. The accept-set measurement the round took (parse result and stored tags identical, only the warning count 0 → 1) is true and reviewable but is explicitly not the basis for the declaration.

⇒ 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

@os-bill
os-bill marked this pull request as ready for review September 12, 2026 19:14
@os-bill
os-bill added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit a61ae59 Sep 12, 2026
47 checks passed
@os-bill
os-bill deleted the claude/issue-17614-email-template-locale-floor branch September 12, 2026 19:42
os-warren pushed a commit that referenced this pull request Sep 16, 2026
…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]>
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 protocol:system size/m tests tooling

Projects

None yet

2 participants