Conversation
…ocale call `email-template.zod.ts` published one rung and "no fallback floor at all"; `contracts/email-service.ts` documents a three-rung ladder whose third rung the runtime and its CI pin both perform. Measured against `EmailService.resolveAndRenderTemplate` and `createSysEmailTemplateLoader`: the three-rung text is correct and the one-rung text is over-broad, so the floor claims are scoped to a call that names a locale and the no-locale rung is stated beside them. Also declares what `warnEmailTemplateLocaleFloor` deliberately does not examine, and corrects the "best-matching locale row" wording the resolver has never implemented. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <[email protected]>
…oundaries The two shapes `warnEmailTemplateLocaleFloor` returns early on were filed under a describe titled "stays silent where the bundle HAS a floor" — false for both: each bundle is floorless and silent because it is outside the guard's scope. Splits them into their own block, pairs every silent case with a warning discriminator so silence cannot be read out of a dead harness, and pins that early return 1 also guards the `supported.map` read. Adds a pin holding the published `locale` describe to naming both call shapes. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <[email protected]>
…changeset `gen:docs` projects `EmailTemplateDefinitionSchema.locale`'s describe into content/docs/references/system/email-template.mdx, so the source correction without this regeneration would have shipped the old sentence to every reader of the reference page. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 a8156c753af3903bf2095ed0bcfd45b0cb0874f5 && git checkout a8156c753af3903bf2095ed0bcfd45b0cb0874f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf61f0a196f8881a825e36793715ebb002c241a3 642da7fda54c4a36f7178367fa902ba12430a9d8 && git checkout -B drift-repro bf61f0a196f8881a825e36793715ebb002c241a3 && git merge --no-ff 642da7fda54c4a36f7178367fa902ba12430a9d8
node scripts/docs-audit/affected-docs.mjs --json bf61f0a196f8881a825e36793715ebb002c241a3
|
Contract reviewServed-tier: ① Derived judgments
Required changes for PASS (text-only, no logic):
② Semver level
③ Boundary flagsopen_question (ADR-0049, early returns): re-derived, I concur with A now and B only as a separately ruled card; C is rejected. Reasoning of my own: the guard exists for the "consistent author" trap, which presupposes an i18n block, so a no-i18n stack is coherently out of its scope; a bundle whose tags all fall outside supportedLocales is a different authoring error (tags not in the supported set) and belongs to a different lint, not this floor check. Widening is a behaviour change on an authoring surface with zero in-tree blast radius measured (both emailTemplates stacks carry en-US), so it is a migration decision, not this text PR. Note ADR-0049 is a security-property gate cited here by repo convention as the general enforce-or-remove policy; the operative rule for this PR is Prime Directive 10 ("keep the claim as narrow as the enforcement"), which the declaration satisfies. Return 1 cannot be removed alone (TypeError, leg 1); any B must add a null-safe read. out_of_scope_findings (4 in the report, not 5):
Implemented-by: VERDICT: FAIL 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]>
|
| reading | value |
|---|---|
| file | .changeset/email-template-locale-floor.md |
| added by | PR #17884, commit a61ae59f93 — a different, already-merged PR, ⛔ not this one |
| released yet? | No. EMAIL_TEMPLATE_FLOOR_LOCALE in the published packages/spec/CHANGELOG.md = 0 (exit 1). Lit control BREAKING in the same file = 241, so the zero is a reading and not a broken grep |
What changed under it
The note says, verbatim:
IEmailService.sendTemplatematches(name, locale)exactly and retries exactly one rung — the literalen-US.New exported
EMAIL_TEMPLATE_FLOOR_LOCALEnames that tag once: it is both the schema default and the resolver's sole retry rung.
An isolated at-tier contract review of this PR (record 5700378995) re-derived the resolver at head and measured that unscoped, both sentences are false for a call that names no locale:
email-service.ts:1316-1329 preferred = input.locale && trim()
wanted = preferred || 'en-US' → load(wanted)
→ load('en-US') if wanted differs
→ load(undefined) ONLY if (!row && !preferred)
template-loader.ts:111-117 undefined → (name,'en-US') else {name} ordered locale asc, id asc
⇒ there is a third rung for a no-locale call — the bundle's lowest locale tag — so «exactly one rung» and «sole retry rung» hold only for a call that names a locale. That is precisely what this PR corrects in the schema's own describe text.
Why leaving the note alone is the worse option
The note is pending, so at the next release changeset version compiles it verbatim into the published CHANGELOG.md — which packages/spec ships in its files[] — placing the false sentence in the published record beside this PR's correction of it. That is the erratum-in-a-later-entry form AGENTS.md:686 forbids.
Amending a pending changeset is ordinary practice in this repo, not an exception invented here: 12babac137 (#16856), a5d4e286b6 (#16874), 2cc4884d1f (#17851), and 12+ more.
The three things NOT done, each on purpose
- ⛔ Not restored from the merge base. That is the COLLISION remedy and the gate names it as the one thing not to do here — it would republish the false sentence.
- ⛔ Not renamed into a fresh
.changeset/<issue>-<slug>.md. Also the COLLISION remedy: it would leave fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's note standing and add a second entry — the erratum shape above. - ⛔
skip-changesetnot applied. There is an open finding ([finding] theskip-changesetlabel suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375) that this label suppresses exactly this DELIBERATE-CORRECTION refusal. Using it here would be doing on purpose what that card reports as a defect.
⇒ What is being asked, and of whom
Maintainer decision, one word: may this PR scope PR #17884's pending, unreleased release note?
- Yes (this seat's recommendation) — the two sentences gain «for a call that names a locale», nothing else in fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's note moves, and the published CHANGELOG stays true. The gate remains red on this one check, so landing also needs the seat's ③ 「every check green」 waived for it by name — ⛔ this seat does not waive it on its own.
- No — the note ships as written, this PR drops that edit, and a follow-up card records that the published CHANGELOG will carry a sentence the same release falsifies.
Filed into this seat's decision batch. ⛔ The PR does not land until this is answered: a red Check Changeset is not covered by the merge-base exception (this failure does not reproduce on the merge base — it exists because of the merge base).
Generated by Claude Code
Contract reviewServed-tier: ① Derived judgmentsWhole-PR runtime delta, derived by comment-stripped
The five prior "Required changes for PASS" — ALL LANDED, with movers (line counts, prior head Edits beyond the required five — each same file, same defect class (an unscoped one-rung/floor claim), none touches logic: (a) "the single literal "No logic moved" — HOLDS, two instruments: (i) line-shape filter over the remediation Dangerous direction — no new sentence claims what the resolver does not do. Boundary calls: named locale, no row, no Completeness — no unscoped one-rung claim survives in ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
At-tier re-review → PASS on head
|
| reading | value |
|---|---|
| record, adopted verbatim | comment 5701154922 |
| reviewer tier by transcript | 121 / 121 claude-fable-5-1; control an os-dev round in the same container 297 / 297 claude-opus-5 |
| independence pair | Implemented-by: claude/issue-18056-email-template-locale-rungs · Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6 |
② --pair 18482 |
exit 0 |
| required contexts | all seven success at 16:42Z |
⛔ What still blocks landing, and it is a decision, not a defect
Check Changeset is red by design on the gate's DELIBERATE CORRECTION arm, and the re-review verified that independently and more precisely than this seat had:
- Provenance re-derived against the TRUE merge base
8fe5cb8e51—⚠️ ⛔ not the API'sbase.sha85c6d76e, which is the base-branch tip, a distinction this seat did not draw. The blob13881152eeis identical at the merge base, atorigin/main, ata61ae59f93(fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's add) and at the prior head; 0 commits onorigin/maintouch it after the merge base ⇒ nothing concurrent is clobbered. - ⭐ A nuance that corrects the framing on both sides: the fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884 note was already false when it was written — rung 3 predates it (email-template-render (b): sendTemplate with no locale renders an arbitrary locale instead of the en-US default #7731). ⇒ this PR did not falsify it; it corrects a sentence that was never true. The remedy is unchanged, but 「your change made it false」 is not the accurate description and should not be the one the decision is taken on.
- Every route out was checked and refused for a stated reason: restoring republishes the false sentence; renaming into a second changeset is the erratum shape
AGENTS.md:686forbids;skip-changesetis for diffs publishing nothing and is open finding [finding] theskip-changesetlabel suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375; waiting for a release turns the edit into an ADD of a stale note.
continue-on-error, and the later steps' if: lacks always(). ⇒ the no-major / Clause-② and ADR-0087 steps have not run in CI on this head at all. The reviewer's local runs (check-changeset-no-major exit 0, check-adr-0087-registration exit 0, against the true merge base) are the only readings of those gates for this head. ⛔ That is a gap to state at landing, not to paper over.
⇒ The ask is narrower than this seat first put it
Check Changeset is NOT one of the seven required contexts (AGENTS.md:505-507 on the head tree), so the merge queue would not block on it. What blocks is this seat's own landing pre-check ③ 「every check green」.
⇒ the maintainer decision (already in the box, comment 5700685091) is two things, and only the first is a product question:
- May this PR scope PR fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's pending, unreleased release note? — this seat's recommendation: yes. Not doing it publishes, in the shipped
CHANGELOG.md, a sentence that was already false and that this same release corrects elsewhere. - A waiver of ③ by name, for
Check Changesetonly, on this head. ⛔ This seat does not waive its own gate; a waiver has to be given, not assumed.
What the re-review settled that the first one could not
All five owed changes landed, with movers re-derived (sole retry rung 1→0, single retry rung 1→0, Its logic is unchanged 1→0, control flow is unchanged 0→1, NAMES a locale 6→10, lit control 23→23, dark 0→0). 「No logic moved」 holds on two independent instruments, each with a live dark control. minor re-derived on the packed tarball (2014 files, new describe in 23, nine old spellings 0 each, lit control BCP-47 locale 64).
⭐ And one thing that had been a puzzle for three rounds today is now explained rather than patched: AGENTS.md's Clause-②⇒minor sentence sits at line 1043 on the shared checkout's branch (blob f04446be) and at 1067-1068 on origin/main (blob fb8bae3e). Re-measured by this seat directly. ⇒ the rounds citing 1043 read the sentence — on a different tree. ⛔ Not a copied number, and the remedy is 「cite origin/main with the sentence quoted」, ⛔ not 「read more carefully」.
Residue
⛔ Nothing new filed from this record: flag 5's stale best-matching locale carriers are already #18499 (filed by this seat at 16:2xZ, same three carriers plus the item-key-discriminators.ts quotation). Flags 1–4 and 6–9 are answered in place with their populations; #18440 is not re-filed by ruling of the record itself.
State
needs:contract-review stripped from both carriers (PR and card #18056), read back and matched. Card stays pm:dispatched with assignee os-warren — it runs to MERGED. ⛔ Ready is not flipped and auto-merge is not attached: a green, reviewed PR whose landing waits on a maintainer answer is not a PR to enqueue.
Generated by Claude Code
…t loop emits during post-apply verification (objectstack-ai#18528) Fixes objectstack-ai#18451 Clause-②: yes (widening) Declares the build-progress PHASE vocabulary on `@objectstack/spec/ai`, executing ruling A on `objectstack-ai/cloud#2172`. The `data-build-progress` frame has shipped as prose only — `AIToolContext.onProgress` documents the channel (`contracts/ai-service.ts:639`) and its example carries a `phase` (`:644`) — while nothing ever declared which phases exist. ## Membership was measured, not designed The ruling names a CLOSED enum, so each member is sourced. Provenance is recorded in the module itself, per member: | member | where it came from | |---|---| | `structure`, `data`, `done` | the consumer's own declared union `ChatBuildProgress['phase']` at `packages/plugin-chatbot/src/ChatbotEnhanced.tsx:163` in objectui, and the set its reader discriminates at `packages/plugin-chatbot/src/mapMessages.ts:744` — `d.phase === 'data' || d.phase === 'done' ? d.phase : 'structure'`. Read in the objectui checkout at `ff1d5ea8d171b65ed5576199807c382a3ccc5b49`. `structure` doubles as that reader's coercion default. | | `verify` | the post-apply verification window objectui#7388 asks the panel to be able to name, and the reason cloud#2172 ruled the vocabulary into the spec. Corroborated in THIS repo by the `verify_build` tool `service-ai-studio` actually registers (`packages/spec/src/system/constants/platform-tool-names.ts`). | **What I could not source, and therefore did not add:** the producer's own emission list. The ruling lives on `objectstack-ai/cloud#2172` and the `cloud` repository is not reachable from this session — the ruling text here is taken from the director seat's filing on the card, verbatim, and the cloud issue is NOT reported as checked. No member was rounded out to fill that gap. `designing` was considered and REJECTED: it belongs to the sibling `data-blueprint-progress` frame, a different channel with its own reader, and a test asserts it does not parse here. ## What this adds - `BUILD_PROGRESS_PHASES` / `BuildProgressPhaseSchema` / `BuildProgressPhase` — the closed vocabulary in lifecycle order. An out-of-vocabulary value is refused and the refusal names the accepted set. - `BuildProgressFrameSchema` / `BuildProgressFrame` — the frame's FLOOR: a required `phase` plus an optional `hop` and `tool`. Deliberately `z.looseObject`, not strict: the panel fields the consumer already reads (`appLabel`, `items`, `done`, `total`, `seq`) ride the same frame and are objectui's to shape, so a strict schema here would refuse every frame shipping today. - `BUILD_PROGRESS_FRAME_TYPE` — the one literal both ends select on. `hop` is typed `z.number().int().nonnegative()` rather than pinned to a base on purpose: whether the loop counts its first hop as 0 or 1 is part of the emitter's placement, which cloud#2172 owns and this card explicitly does not decide. `tool` is a free string, not a closed set, because the executable tool set is registered at boot and legitimately includes plugin-contributed names `PLATFORM_PROVIDED_TOOL_NAMES` cannot know about. `api-surface` reads **6 added / 0 removed** on `./ai` — purely additive, which is what the `minor` changeset and the `Clause-②: yes (widening)` declaration record. ## Tests Everything below was run on final head `3233eea673`, after `pnpm --filter @objectstack/spec build`. - **New pin** `packages/spec/src/ai/build-progress.test.ts` — 16 tests, exit 0. - **Affected package** `pnpm --filter @objectstack/spec test` — `Test Files 484 passed (484)`, `Tests 13789 passed (13789)`, `VERDICT command-exit 0`. - **Typecheck** `pnpm --filter @objectstack/spec typecheck` — exit 0. Note the main `tsc --noEmit` program does NOT include `**/*.test.ts` (verified with `--listFiles`: 0 hits for both test files, 1 for the source); the test layer is covered by the `tsconfig.test.json` leg, where a `grep` for my files over the raw error output returns **zero** hits with a lit control finding 5 elsewhere. That is what proves the two new ADR-0122 assertions. - **Derived gates** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **108** families; all 108 were run on this head and reconciled with exit codes via `--ran`: *"108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN"*. **107 exit 0.** - **Repo-wide** `pnpm lint` (`eslint . --no-inline-config`) — exit 0, run in full, not narrowed. ### The one non-zero, and why it is not this diff `pnpm check:cross-package-test-inputs` exits 1, naming `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/`. This diff touches neither that test nor `packages/cli`. Two legs on a throwaway worktree at pristine `origin/main` (`97233b90ca`), with **zero** of my source changes: - no `packages/spec/dist/` present: **exit 0** - the same tree with only a built `packages/spec/dist/` copied in: **exit 1**, byte-identical finding - that `dist/` removed again: **exit 0** So the trigger is the presence of a gitignored build output — which AGENTS.md requires before the dist-reading gates — not this change. Filed as an out-of-scope finding in the report. ### Ablation — the pin is not vacuous Run from the committed state, each leg proving the mutation reached disk (occurrence counts of both the removed and the injected text, plus a blob hash differing from the HEAD blob) before any verdict was read, and restoring by observed state (`git checkout HEAD -- path`, blob hash back to `53034bc672c8de6dbe800cd923bba8b7f237c66d`, `git diff HEAD` empty) rather than by exit code: | mutation | result | |---|---| | `z.enum(BUILD_PROGRESS_PHASES)` becomes `z.string()` — the vocabulary stops being closed | **3 failed / 13 passed**: all three refusal cases red, every known-phase control still green | | `z.looseObject` becomes `z.strictObject` — the floor becomes a ceiling | **1 failed / 15 passed**: exactly the case asserting the consumer's shipping panel fields survive | ## Acceptance notes ### File-surface accounting — all 17 files, in three buckets The claim declared `packages/spec/src/ai/**`, the `contracts/ai-service.ts` docblock, the generated `api-surface` / `export-origins` / reference-page artifacts, and `.changeset/`. **Nothing here is a hand edit I chose** — bucket 3 is empty. **Bucket 1 — generated (10 files).** Proven, not asserted: every one was reverted to the merge base `fb6b2c369e` and re-produced by re-running the generators on that tree (`check:generated --fix` wrote `gen:schema`, `gen:api-surface`, `gen:export-origins`, `gen:docs`, `gen:strictness-ledger`; `gen:declaration-map` was run explicitly because the aggregate does not flag it when its companion is reverted in the same stroke). `git diff HEAD` over all ten came back **empty** — byte-identical reproduction — and the tree was then restored with `git restore --source=HEAD --staged --worktree`, index and tree both clean. | file | generator that wrote it | |---|---| | `packages/spec/api-surface/ai.json` | `gen:api-surface` | | `packages/spec/export-origins/ai.json` | `gen:export-origins` | | `packages/spec/declaration-map/ai.json` | `gen:declaration-map` | | `packages/spec/authorable-surface/ai.json` | `gen:schema` (via `check:authorable-surface`) | | `packages/spec/json-schema.manifest/ai.json` | `gen:schema` | | `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md` | `gen:strictness-ledger` | | `content/docs/references/ai/build-progress.mdx` (new page) | `gen:docs` | | `content/docs/references/ai/index.mdx` | `gen:docs` | | `content/docs/references/ai/meta.json` | `gen:docs` | | `content/docs/references/index.mdx` | `gen:docs` | **Bucket 2 — required by a gate, with the gate's own red quoted (3 files).** - `packages/spec/src/type-alias-convention.pin.test.ts` — `pnpm check:spec-parsed-alias` red: *"`BuildProgressFrame` is the AUTHOR state of `BuildProgressFrameSchema` and nothing names its PARSED state. Declare `export type BuildProgressFrameParsed = …` next to it … or, if `z.input` and `z.infer` of `BuildProgressFrameSchema` are the same type, pin it in `packages/spec/src/type-alias-convention.pin.test.ts` instead."* Both schemas are isomorphic, and that file's own header states why the pin beats a synonym: *"a permanent synonym is a name an author can only pick wrongly."* So this is the ADR-prescribed route, not a preference — the file is also the gate's machine-readable exemption registry, which is why the registration must live there and nowhere else. - `packages/spec/llms.txt` — `pnpm check:llms-txt` red: *"`[count]` domain `ai` declares 11 schemas; `src/ai/` holds 12"* and *"heading declares 200 schemas; `packages/spec/src/` holds 201"*. Hand-kept by design — the gate says *"there is deliberately no `gen:llms-txt`"* — and it ships to AI consumers inside the npm tarball. - `content/docs/getting-started/quick-reference.mdx` — `pnpm check:quick-reference-counts` red: *"section \"AI Protocol\" declares \"of 11 schemas\" but `content/docs/references/ai/` publishes 12 page(s)"*. **Reduced to the gate-required minimum**: a one-line `11 of 11` to `11 of 12`. An earlier revision of this branch also added a table row for the new page; that was discretionary polish, not needed for correctness, and it was dropped — the section now carries the same partial shape the API section already has at `17 of 31`. **Bucket 3 — my own judgement calls: none.** If every path above reads as bucket 1 or 2, the claim's surface wording was narrower than what one new exported schema mechanically forces in this tree. - **`contracts/ai-service.ts` was NOT edited.** The claim allowed its docblock "if the frame example should name the enum". The new module's own docblock points at that file, and leaving the contract file untouched keeps the diff off a path a sibling PR is in (`email-service.ts`, PR objectstack-ai#18482, is the only `contracts/` file in flight). Naming the enum from the `onProgress` docblock is a good follow-up, not a requirement of this card. - **No `XParsed` aliases were added.** Both schemas are isomorphic, and ADR-0122's pin file states the reason a permanent synonym is worse than none: it is a name an author can only pick wrongly. The pins are the prescribed route and tsc proves them. - **Not merged with `main`.** The branch is based on `fb6b2c369e`. Independence was verified for these paths, and the three hottest spec artifacts are sharded per domain precisely so parallel spec PRs stay textually disjoint. The merge queue rebuilds on the merged generation. Declared rather than assumed. - **Noted, not filed:** the sibling `extractBlueprintProgress` reader in objectui collapses an unknown `data-blueprint-progress` phase to `'designing'` by the same pattern. It is out of scope here and was already ruled on in objectui#7388 (its docblock makes "only `done` is authoritative" a contract, so the posture is deliberate). Carrier: whoever picks up the `data-blueprint-progress` vocabulary, if that is ever ruled. ## Scope this card does not decide Per the card: not the UI copy for each phase (objectui#7388's), and not the emitter's placement in the cloud agent loop (cloud#2172's). Authored by the `domain:spec` execution seat, session `session_01KB5PFtxuy1x3dcR5gxudx6`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <[email protected]>
Fixes #18056
packages/specstated two different rung counts for one resolution. Thisdecides 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; thisbody restates it for legibility. The changeset is
minor, which that declarationrequires, and the diff genuinely ships (measured below).
Remediation round (text-only, no logic moved)
An isolated at-tier contract review of head
708595dc9creturned FAIL on thecompleteness of the surrounding claims, not on the work: the three-rung reading, the
untouched docs page and the Half B ablation all verified.
642da7fda5closes the fiverequired items, every one of them text:
.changeset/email-template-locale-floor.md— the pending, unreleased note fromfix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884 (
a61ae59f93, 2026-09-12: the only commit that has ever touched that file, andits
--diff-filter=Aadd) still said "retries exactly one rung — the literalen-US" and "the resolver's sole retry rung", unscoped.changeset versionwouldhave compiled both verbatim into the published
packages/spec/CHANGELOG.md— whichthat package ships in its
files[]— beside this PR's correction of them: theerratum-in-a-later-entry form
AGENTS.md:686forbids. Both are now scoped to a callthat NAMES a locale, with a pointer to
SendTemplateInput.localefor the full ladder;the same file's "the single literal
en-USrung" is scoped for the same reason.packages/spec/src/stack-email-template-locale-floor.test.ts:12-14— header sentencescoped, with the no-locale case named beside it. Its title line's bare "no fallback
floor" is scoped the same way.
packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts:33— "itssingle retry rung" now names both rungs and which call shape reaches each.
Half B, where "byte-for-byte" is gone and "control flow unchanged" stays.
sys-email-template.object.tsis corrected — see Acceptancenotes.
⛔
Check Changesetis RED on this head by design, and must not be turned green. Thegate refuses because
.changeset/email-template-locale-floor.mdexists on the merge baseand 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-changesetsuppressing thisvery 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. Thegate 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
.tsfiles were reprinted throughthe TypeScript printer with
removeCommentsand hashed: stripped sha256c026b27d…and98e79f85…, byte-identical before and after, while both raw file hashes changed. Darkcontrol on a pair that really does move code (
8fe5cb8e51..708595dc9constack.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.resolveAndRenderTemplateandcreateSysEmailTemplateLoaderin@objectstack/plugin-email:en-US— also where a call naming no locale startsen-USrowA call that names a locale never reaches rung 3: it dead-letters with
TEMPLATE_NOT_FOUND(permanent). A call that names none never dead-letterson a non-empty bundle.
Verdict:
contracts/email-service.tswas right andsystem/email-template.zod.tswas 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-USrow fails withTEMPLATE_NOT_FOUND".examples/app-showcase/src/system/emails/index.ts— "exact match →en-US→(no-locale calls only) the bundle's lowest tag".
plugin-email/src/template-locale-resolution.test.ts— rung 3resolving 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— onerung 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/specevery floor claim is now scoped to "a call that NAMES a locale", theno-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.templateTSDoc said the service "picks the best-matching localerow". 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-USwere called "different bundles" one paragraph after the TSDocdefines a bundle as rows sharing one
name. They are different rows of onebundle; the rewritten sentence says so.
Both sides of the generated projection, all four numbers
That
describeprojects intocontent/docs/references/system/email-template.mdx.A source-only fix would have left the old sentence on the reference page:
packages/spec/src/system/email-template.zod.tscontent/docs/references/system/email-template.mdxControl:
BCP-47 localestill returns 1 in the generated file, so the zero above isa real absence and not an unreadable path. Before
gen:docsthe generated column read1 / 0— the trap, caught and closed.check:generatednow reports all 15 artifactsup to date.
It ships, measured on the built artifact
packages/specpublishesdistandsrc/**/*.zod.ts. In the built tree: 16files 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.
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.
supportedLocalesis REQUIRED insidei18n, so its absent arm is reachableonly via a stack with no
i18nblock; and with no supported set every bundle'sdeclaredlist is empty, so early return 2 skips exactly the same shapes. Whatreturn 1 actually buys is not reading
.mapoffundefined— deleting it takesdefineStackdown 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 ownconfig, real
allEmailsmodule) andpackages/qa/dogfood/.../email-template-materialization-fixture.ts(whole stack). Both carry an
en-USrow, so both hit the floor check before eitherearly return is reached.
en) has no fallback floor: the resolver retries only the literalen-US, so every unlisted recipient locale dead-letters permanently #17614 trap shape: 1 warning — the harness is not blindThe 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 thecommitted state, under a
trap ... EXIT INT TERMwith absolute paths.c50c54efae7ba260b6vs HEADa9f45392)4c24ea22vs HEADa9f45392)describeback (NAMES a locale2 → 1, blob0c068b72vs HEAD915c2e31)Restore verified each time:
RESTORED-OK … blob == HEAD, finalGIT_DIFF_HEAD_EMPTY=true. No ablation artefact is left in the tree.Verification
pnpm --filter @objectstack/spec test— 483 files, 13777 tests passedpnpm --filter @objectstack/spec typecheck— cleanpnpm --filter @objectstack/spec check:generated— all 15 artifacts up to dateeslint . --no-inline-configover the full repo population: 6796 files, 0errors, 0 warnings, exit 0 — run at final commit
708595dc9c, so no narrowingclaim is needed
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 3PREREQUISITE NOT MET, an unbuilt workspace closure, which CI builds fresh)origin/mainworktree:
check:cross-package-test-inputs. Leg A (all six of this PR's paths, nopackages/spec/dist/) → exit 0. Leg B (zero of this PR's paths, plus an emptypackages/spec/dist/) → exit 1, identical finding. It reds on the presence of alocal spec build, not on this diff. Already filed as [finding]
check:cross-package-test-inputsanswers 1 or 0 depending on whetherpackages/spechas been BUILT — the author who follows AGENTS.md is the only one who sees the red, and CI never does #18440 (open,2026-09-16T10:52Z) — ⛔ not re-filed here. Re-measured on this round's worktree with no
packages/spec/distpresent: exit 0, the same pre-build leg.This round (
642da7fda5)pnpm --filter @objectstack/spec exec vitest runover 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$?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-inputscheck:empty-changeset— exit 1, RED BY DESIGN (DELIBERATE CORRECTION arm, above).Its own
--self-testpasses, 159 assertions, so the instrument is sound.Acceptance notes
Noted, not filed — each with who would meet it:
packages/spec/src/system/email-template.form.tssays nothing about the floor, so theStudio authoring path teaches none of the above. A gap, not an error. Successor: the
next card touching the email-template authoring form.
packages/platform-objects/src/audit/sys-email-template.object.ts:10-11does 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 therecipient, falling back to
en-US" — verbatim the third of the three false declarationsthat
packages/plugins/plugin-email/src/template-loader.ts:20-22already names. The sameclaim 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.tsquotesemail-template.zod.tsas saying the service "picks the best match for therecipient's locale" — measured 0 hits there (controls
i18n bundle= 1,must stay equal= 1). The sentence lives, in a variant, incontracts/email-service.ts, and this PR corrects that variant, so the quotation isnow doubly stale. Successor: the next card on email-template identity keying.
describeequal to the runtime'sladder would close this class mechanically, in the shape
email-template-floor-locale-parity.pin.test.tsalready uses for the constant.Successor: none today — offered to the seat as a follow-up.
Generated by Claude Code
Generated by Claude Code