Skip to content

feat(formula, plugin-email, lint)!: a mail template hole whose text after the | is not a formatter name is refused by name, and mail reads every formatter part as the in-app dialect does - #27270

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-e0g649
Oct 28, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread
Fixes #27225

Claim: 6469619404
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — skills/objectstack-formula/SKILL.md:444 teaches a template hole as {{ path | formatter[:arg] }} with the TEMPLATE_FORMATTERS whitelist, which is exactly the shape this PR enforces, and no skills/** file teaches or shows a mail hole whose formatter part is not that shape (grep over skills/** for formatter, emailTemplates and email_template: the hits are that row, the automation skill's "a variable path, optional formatter, no logic" sentence, and two unrelated uses of the word). The docs page content/docs/automation/email-templates.mdx gains the rule here.

维护者速读

  • 改了什么: 邮件模板里 | 后面写的不是一个格式化器名(如 {{ due | date-long }}、{{ x | two words }}、{{ x | }})时,现在在 os validate / os build / os lint 和元数据发布门(422 INVALID_METADATA)被按名拒绝,说明正确写法(一个词,可带 :参数)并列出全部格式化器;已存的旧模板渲染时报 TEMPLATE_INVALID,不再把原文发给收件人。同时邮件与应用内模板改用同一个切分函数,{{ amount | currency :EUR }}、default:it's 这类应用内本来就接受的写法,邮件里也照应用内的读法渲染。
  • 为什么改: 原来这种写法通过所有门禁,邮件正文里直接出现 {{ due | date-long }} 原文;应用内模板早就拒绝,两种方言不一致(feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217 合同复核 S1)。
  • 风险与回滚: 收窄(BREAKING),只影响格式化器写成非单词的邮件模板;仓内出厂模板、示例、种子、技能零命中。另有一类读法变化:邮件里每个双引号参数现在都去掉首尾引号,与应用内一致(default:"n/a"、date:"long"、number:"2"、truncate:"80"、default:"");其中 currency:"EUR" 以前被 currency-code-literal 拒收,现在可发布、按 EUR 渲染(一处放宽,级别仍由收窄决定)。回滚即 revert 本 PR。
  • 先看: packages/formula/src/template-engine.ts(新增 splitHoleFormatter 与 formatter-shape 规则)、packages/formula/src/mail-hole-grammar.ts(去掉 FILTER_RE,改读共享切分)、packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts(原样输出的边界重钉)。
  • 两分钟验证: pnpm --filter @objectstack/formula exec vitest run src/template-formatter-shape.test.ts,预期 10 个用例全绿,其中「两种方言接受完全相同的格式化器写法」逐条比对 21 种写法。
  • 需要你做的: 无,席位落地(与 feat(formula, plugin-email, messaging, automation, ui)!: a template currency hole with no argument formats in the organization's default currency, and with none the render refuses it #27249 串行,后落地者合入 main)。

What changes

  • @objectstack/formula, one tokenizer for both dialects (template-engine.ts). New splitHoleFormatter(formatterPart) splits the text after a hole's first | at its first :, trims both sides and removes one enclosing quote mark at each end of the argument. This is the in-app parseHole's own code, moved out unchanged. parseHole and the mail grammar's parseMailHole both read it, so the two dialects now accept exactly the same formatter parts (the seat's ruling on the claim). The function is exported from the module for the mail grammar; the package index exports nothing new.
  • @objectstack/formula, a fourth rule, formatter-shape (judgeHoleFormatter). It is judged after one-formatter and before known-formatter: a formatter name that is not one word is refused. The sentence names the hole, says what it wrote where the formatter name goes (or that it wrote none), states the shape {{ path | formatter[:arg] }} and lists TEMPLATE_FORMATTERS. known-formatter keeps refusing a one-word unknown name, so {{ x | curency :EUR }} still reads known-formatter. TemplateHoleGrammarRule gains 'formatter-shape'.
  • @objectstack/formula, the mail grammar (mail-hole-grammar.ts). Its own FILTER_RE is gone. A path followed by | now always parses as a hole; a formatter part the grammar refuses rides as refusal, so judgeMailHoleGrammar reports it. parseMailHole still answers null only when the part before any | is not a path.
  • The in-app dialect: no accept set moves. Its compile refused every formatter part this PR refuses, before and after, and keeps its own "invalid template hole" sentence (pinned).
  • @objectstack/plugin-email: no code change in render(). It already throws TEMPLATE_INVALID for a parsed hole that carries a refusal, so these holes are refused at render. Only the header comment names the new case.
  • @objectstack/lint email-template-hole-grammar and the publish door: no code change. They read judgeMailHoleGrammar. The rule's header and the authoring-rules.ts roster comment name the new case.
  • @objectstack/spec: MAIL_TEMPLATE_HOLES (the subject / bodyHtml / bodyText descriptions) states the shape, and the IEmailService TEMPLATE_INVALID TSDoc names it among the refused holes. content/docs/references/system/email-template.mdx is regenerated.
  • Docs: content/docs/automation/email-templates.mdx gains a paragraph on the rule, the shared reading and the verbatim boundary. The stale sentence "An unknown formatter falls back to the raw value rather than failing the render." (false since feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217) is deleted from the same page.
  • Changesets: formula, plugin-email and lint minor (narrowed, (narrowing) arm, ADR-0087 not-required (no-migration-prescription): a conversion cannot know which formatter was meant). Spec patch (describe and TSDoc only, none). The formula entry carries a "Supersedes, in the same release" paragraph, because mail templates: an unknown hole formatter (e.g. a misspelled curency) is refused nowhere and renders the raw value; the in-app dialect refuses it (found reviewing #26660) #26948's unreleased entry says non-word formatter text "is left as written", which is no longer true.

The verbatim boundary (text-field-html-outlets.pin.test.ts:284)

Decided and pinned: brace text whose part before any | is not a path is the template's own text and stays verbatim ({{ not a path }}, {{}}, {{ | upper }}, {{ a b | upper }}, {{ | date-long }}). A path followed by | is a formatter use and is judged. The corpus line at :284 drops {{ x | two words }} and gains {{ a b | upper }}. A new case in the same pin renders the literal set unchanged through the real path and refuses {{ x | two words }}, {{ due | date-long }}, {{ x | }} and the triple-braced form in both entry points.

Census (on origin/main 9ff8154c, which contains #27217)

Every git-tracked file was scanned for a mail-grammar hole whose path parses, followed by | and a formatter part the old FILTER_RE failed. Shipped mail templates (plugin-email auth-templates.ts, examples, apps, seeds, dogfood and downstream fixtures, skills/**) have 0 hits. The 79 raw hits are grammar prose in TSDoc, docs, CHANGELOGs and changesets; chained holes the one-formatter rule already refuses; GitHub Actions || expressions in .github/ and scripts/; ui/cliff.toml (a Tera template, not mail); and a source-escaped currency: \'USD\' that parses at runtime. Two test lines pinned the old verbatim print: the outlets pin :284 and template-unknown-formatter.test.ts:45. Both are re-pinned here. No mail template in the repo writes a double-quoted argument, so the double-quoted-argument reading change (below) touches none.

Merge order with #27249

Serial with #27249 (#26939). That PR edits the plugin-email render() hunk, formula template-engine.ts, template-hole-grammar.test.ts, email-templates.mdx and the IEmailService TSDoc. This branch leaves render() alone (its only plugin-email source edit is three lines in the header comment, clear of #27249's hunks). Whichever lands second merges main (no rebase).

Tests

  • New packages/formula/src/template-formatter-shape.test.ts (10 cases): the tokenizer; the refusal and its sentence (named hole, shape, known list, and the empty case); one-formatter still winning on a chained hole; parseMailHole carrying the refusal; the in-app readings (currency :EUR, default:it's, default:"n/a", currency :@currency_code); the literal set staying null; judgeMailHoleGrammar locating refused holes, triple-braced included. ⭐ Dialect parity: 21 formatter parts, each compiled in-app and parsed in mail, with the same verdict asserted on both sides (12 accepted, 9 refused), currency:"EUR" and date:"long" among the accepted. The in-app sentence is unchanged.
  • Re-pinned packages/formula/src/template-unknown-formatter.test.ts: "text after the | that is not a word names no formatter, and the mail grammar leaves it verbatim" is now "…is refused by the formatter-shape rule, never left verbatim".
  • New packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts (5 cases): renderTemplate refuses on all three faces with the template, locale, hole and shape; sendTemplate sends nothing; the in-app readings render (€1,234.50, it's, 2026-10-28, n/a).
  • packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts: the boundary case above, plus the corpus edit.
  • New packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts (9 cases): all three faces; a template using currency :EUR, date :long and default:it's draws nothing; literal brace text draws nothing; os validate / build / lint; the runtime publish gate.
  • New packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts: the 422 INVALID_METADATA envelope (code and status, plus issues), and the corrected template (with currency :EUR) publishing.

Acceptance notes

  • The two dialects still differ in the PATH part, not here. The in-app path admits $ and [i] (PATH_ONLY_RE), the mail path does not (PATH_RE), so {{ $error.message | upper }} is a hole in-app and literal text in mail. That is the path grammar, outside this card, and it is unchanged.
  • Every double-quoted argument changes reading, and one spelling widens. The mail grammar kept double quotes as part of the argument; the shared tokenizer removes one enclosing quote mark at each end, as in-app always did. So default:"n/a" supplied "n/a" and supplies n/a; date:"long" fell back to the short style and is long; number:"2" had no fixed digits and has two; truncate:"80" did not truncate and cuts at 80; default:"" supplied "" and supplies nothing. currency:"EUR" was refused by currency-code-literal at every door and at render, and now publishes and formats in EUR: that one spelling is a widening. The narrowing is the BREAKING arm, so the declarations and the levels do not move. The formula and lint changesets say so (their earlier "nothing refused is accepted" / "no accept set is widened" sentences were false and are corrected). The census found no mail template with a double-quoted argument.
  • known-formatter and formatter-shape overlap by design. Without the WORD_RE guard, known-formatter would also catch date-long, but its sentence ("names the formatter 'date-long'") reads as a misspelling. formatter-shape states the shape instead.

Verification

All readings below are on head b0fe9278, except where a later line names 4bc8e16d (branch off origin/main 9ff8154c, origin/main 4d3a077f merged in, which touched only driver-sqlite-wasm/vitest.config.ts).

Before and after, the mail grammar and the in-app compile

Before (origin/main 9ff8154c): parseMailHole(' due | date-long '), ' x | two words ', ' x | ', ' amount | currency :EUR ' and " note | default:it's " all answered null. judgeMailHoleGrammar reported nothing, and the renderer printed each hole as written. In-app: date-long, two words and the empty part were refused at compile; currency :EUR and default:it's compiled.

After (b0fe9278): the first three carry refusal.rule === 'formatter-shape' and the renderer throws TEMPLATE_INVALID. currency :EUR parses as { formatter: 'currency', arg: 'EUR' } and renders €1,234.50; default:it's parses with arg: "it's". In-app verdicts are unchanged on all 19 parts of the parity table.

Tests and typecheck
  • pnpm --filter @objectstack/formula exec vitest run --maxWorkers=2: 59 files, 1541 tests. One failure on the first run was the new test's own offset (expected 62, actual 60), corrected; then the three grammar files 43/43.
  • pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2 (formula dist rebuilt first): 43 files, 735 tests, all passed.
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2: 174 files, 7552 tests, all passed.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run src/runtime-authoring-gate.email-template (closure rebuilt first): 3 files, 8 tests, all passed.
  • pnpm --filter @objectstack/formula --filter @objectstack/plugin-email --filter @objectstack/lint --filter @objectstack/metadata-protocol typecheck: all four ran (tsc --noEmit, plus check:test-typecheck for the first three), VERDICT command-exit 0.
  • Round 1 (4bc8e16d; changesets, one docs line and template-formatter-shape.test.ts only): pnpm --filter @objectstack/formula exec vitest run src/template-formatter-shape.test.ts 10/10 passed, with the parity table at 21 parts. The dogfood battery on b0fe9278 carries over (no src/** moved): 348 of 351 files and 2804 of 2824 tests passed, the rest skipped.
  • pnpm --filter @objectstack/spec build && check:generated: check:docs was stale (content/docs/references/**), --fix regenerated references/system/email-template.mdx, and the rerun reports all 14 artifacts up to date.
Ablation

With the fix committed, node scripts/ablation-replace.mjs added if (formatter === formatter) return null; at the top of the formatter-shape branch of judgeHoleFormatter (anchor 1 → 0, blob 6014dfef → 7f1c5707). This simulates the old answer, where a non-word name passes the judge. Then it ran template-formatter-shape.test.ts and template-unknown-formatter.test.ts: 6 failed | 13 passed, the refusal, sentence, empty-part, mail-parse, face-location and dialect-parity cases. The restore confirmed the blob equals HEAD, and git diff HEAD was empty. The formula tests import from src, so that leg needed no rebuild. The plugin-email, lint and metadata-protocol legs read formula's dist and were not ablated.

Derived gates

node scripts/pm/dispatch-gates.mjs --commands on b0fe9278 derived 106 families. All were run serially, each line recorded as CMD :: exit N, and --ran reconciled: 106 derived, 104 run with exit 0, 2 NOT MEASURED, 0 unrun.

  • NOT MEASURED (exit 3, PREREQUISITE NOT MET, no full pnpm build in this tree): pnpm --filter @objectstack/spec run check:skill-examples and pnpm check:dual-build-cjs-loads. Both read built output of packages this PR does not touch; CI's full build carries them.
  • Green families that read this diff include check-adr-0087-registration --base origin/main, check-changeset-no-major --base origin/main, check-empty-changeset --base origin/main, check:type-check-debt and check:undeclared-dep-imports.
  • test:repo was not run locally (CI's Test Core runs it).

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/formula, @objectstack/lint, @objectstack/plugin-email, @objectstack/spec, touching 11 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/lint/src/validate-email-template-hole-grammar.ts, packages/plugins/plugin-email/src/template-engine.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via IEmailService (symbol, a top-level interface))
  • content/docs/deployment/self-hosting.mdx (via IEmailService (symbol, a top-level interface))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))
  • content/docs/kernel/contracts/index.mdx (via IEmailService (symbol, a top-level interface))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via IEmailService (symbol, a top-level interface))
  • content/docs/releases/v18/18-1.mdx (via IEmailService (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/lint/src/validate-email-template-hole-grammar.ts, packages/plugins/plugin-email/src/template-engine.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 148 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 0d45c0e71f0a03bdcf2230901515c2a29716a92b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 090c5839486df9fa3d7f7f2b7a718fc6dc795fdd — the merge of head 4bc8e16d39df2740f685b5aa0d82036ab7d2babb into base 0d45c0e71f0a03bdcf2230901515c2a29716a92b, 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 090c5839486df9fa3d7f7f2b7a718fc6dc795fdd && git checkout 090c5839486df9fa3d7f7f2b7a718fc6dc795fdd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d45c0e71f0a03bdcf2230901515c2a29716a92b 4bc8e16d39df2740f685b5aa0d82036ab7d2babb && git checkout -B drift-repro 0d45c0e71f0a03bdcf2230901515c2a29716a92b && git merge --no-ff 4bc8e16d39df2740f685b5aa0d82036ab7d2babb

node scripts/docs-audit/affected-docs.mjs --json 0d45c0e71f0a03bdcf2230901515c2a29716a92b

⚠️ 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 0d45c0e71f0a03bdcf2230901515c2a29716a92b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b0fe92782a38a43cc19bfd3ee97271a07cbf9f00
Local-runs: none
Scope: packages/spec/src/contracts/email-service.ts, packages/spec/src/system/email-template.zod.ts + packages/formula/src/template-engine.ts, packages/formula/src/mail-hole-grammar.ts, packages/formula/src/template-formatter-shape.test.ts, packages/formula/src/template-unknown-formatter.test.ts, packages/lint/src/validate-email-template-hole-grammar.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts, packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts, packages/plugins/plugin-email/src/template-engine.ts, packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts, packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts (the floor: every non-prose path) + cited and read, not reviewed: packages/formula/src/index.ts, packages/formula/package.json (exports), packages/formula/src/mail-html-hole-placement.ts, packages/plugins/plugin-email/src/email-service.ts, .changeset/26948-mail-unknown-formatter-formula.md, skills/objectstack-formula/SKILL.md:444, ADR-0087 and scripts/check-adr-0087-registration.mjs. Changesets, the two content/docs/** pages and the regenerated reference are not reviewed. Base: main at 1287b2fa; head is 4 commits ahead, 9 behind (merge base 4d3a077f); the diff read is GitHub's three-dot diff, which is the net diff against that base.

Ruling applied: claim 6469619404 on #27225 — the mail grammar accepts exactly the formatter parts the in-app compile accepts and refuses the rest; in-app is not narrowed.

① Derived judgments

Every judgment below is read off the code at the head and at the base, not off the PR body.

  1. Mail refuses a path followed by | whose formatter name is not one word — right. parseMailHole no longer runs its own FILTER_RE; it splits the formatter part with splitHoleFormatter and hands the name to judgeHoleFormatter, whose new first branch after one-formatter returns formatter-shape when !WORD_RE.test(formatter). A path + | therefore always parses, carrying the refusal, and every reader of parseMailHole / judgeMailHoleGrammar follows with no code change: the lint rule (os validate, os build, os lint), the runtime publish door (422 INVALID_METADATA, pinned in metadata-protocol) and the renderer (render() already throws TEMPLATE_INVALID on parsed.refusal; email-service.ts prefixes the template name and locale). Before, these holes were null and printed to the recipient verbatim. The published spec on main (MAIL_TEMPLATE_HOLES, the skill row at SKILL.md:444, the docs page) declares the shape {{ path | formatter[:arg] }} with a formatter the engine knows and never declared non-word formatter text as anything: this makes the spec's own refusal real.

  2. Mail now also judges a path + | whose name is a word but whose part the old FILTER_RE failed — right. {{ x | curency :EUR }} reads known-formatter (as before, by a different route); {{ amount | currency :currency_code }} was printed verbatim at the base (FILTER_RE failed, the judge saw no argument) and now reads currency-code-literal. Both are the grammar's existing rules reaching holes they could not see.

  3. Mail widens to the in-app reading (the ruled-for parity) — right, one part undisclosed. Parts the in-app dialect accepts and mail printed verbatim at the base now parse and render: a space before the colon (currency :EUR, date :long), an apostrophe in the argument (default:it's, default:'it's', default:n'a), and currency :@currency_code (the @path form, codePath still resolved). One widening the PR and all three changesets deny: currency:"EUR" (and currency: "EUR") was REFUSED at the base — FILTER_RE kept the double quotes in the argument, isCurrencyCode('"EUR"') is false and currencyCodePath found no @, so currency-code-literal fired at every authoring door and at render. At the head the quotes are stripped and the hole publishes and renders €1,234.50. That is the ruling's outcome (in-app accepted it before and after), so the behaviour is right; the formula changeset's sentence "No authoring door accepts anything it refused before" and the lint changeset's "No accept set is widened" are false for it. See S1.

  4. Mail reads every double-quoted argument without its quotes — right, disclosed as one instance where it is a class. splitHoleFormatter strips one ' or " at each end; FILTER_RE stripped only '. So every name:"arg" mail already rendered now renders differently, not only default:"n/a" → n/a: date:"long" (short style → long style), truncate:"80" (no cut, Number('"80"') was NaN → cut at 80), number:"2" / percent:"1" (unformatted → formatted), default:"" ("" → empty). Each is the in-app reading, so right under the ruling; the census (0 double-quoted arguments in any shipped template) covers the class. See S1.

  5. In-app behaviour is unchanged, byte for byte — proven from the moved code. splitHoleFormatter is parseHole's colon split verbatim: no colon → { name: part } (so arg destructures to undefined, as the old let arg did); else name = slice(0, colon).trim(), arg = slice(colon + 1).trim().replace(/^['"]|['"]$/g, ''), the same two expressions. The judge's new formatter-shape branch fires for in-app too, but parseHole then falls through to the unchanged if (!FORMATTERS[name]) return null, and no key of FORMATTERS fails \w+ (its own keys are the eleven formatter names; the inherited Object.prototype keys are all words), so for every non-word name parseHole returns null exactly as before and compileTemplate emits the same "invalid template hole … Known formatters" sentence. For a word name the condition WORD_RE.test(f) && !TEMPLATE_FORMATTERS.includes(f) became !TEMPLATE_FORMATTERS.includes(f) under a guard that is now always true: the same verdict. evaluate() re-parses with the same function. Pinned by the 19-part parity test (10 accepted, 9 refused, both dialects) and the in-app-sentence test.

  6. No mail spelling that rendered correctly at the base is refused at the head. Enumerated from the two tokenizers: every part the old FILTER_RE matched has a \w+ name, so formatter-shape cannot fire on it; one-formatter reads the unchanged formatterPart; the only argument that reads differently is the double-quoted class (item 4), which can only REMOVE a currency-code-literal refusal, never add one. The set of refused-at-the-head mail holes is the base's set plus items 1 and 2 only.

  7. Public surface — right. splitHoleFormatter and HoleFormatterParts are exported from the module only; packages/formula/src/index.ts names its exports (no export *) and the package exports map has only ., so the package index exports nothing new, as the changeset says. TemplateHoleGrammarRule (a public type) gains 'formatter-shape': a widening of a union; the in-repo readers (lint, plugin-email, the placement judge) read .rule only to word a finding and no exhaustive switch exists. Disclosed in the changeset, as mail templates: an unknown hole formatter (e.g. a misspelled curency) is refused nowhere and renders the raw value; the in-app dialect refuses it (found reviewing #26660) #26948 disclosed 'known-formatter'.

  8. The placement judge changes with it — right, not named in the PR. judgeMailHtmlHoles reads parseMailHole, so a path + | + non-word text is now a hole there too (and so for validateEmailTemplateHolePlacements). Benign: the grammar rule refuses the hole first, and the "same spans" pin still holds by construction because the corpus no longer carries a refused spelling and the new boundary case asserts the literal set is [] for the judge and verbatim for the renderer.

  9. Spec: describe() text and TSDoc only — right. No schema accept set moves; content/docs/references/system/email-template.mdx is regenerated from it. patch, Clause-②: no, Metadata-protocol: none.

② Semver level

③ Boundary flags

④ Design judgment

The narrowing, against the published spec on main. It takes no declared capability away at the consumer. The spec declared {{ path | formatter[:arg] }} with one formatter the engine knows; date-long, two words and an empty formatter part were never declared, were refused in-app, and in mail reached the recipient as formatter syntax only because parseMailHole returned null and the renderer's "not a hole" branch printed it. The refusal is placed in the shared judge, so the three doors and the renderer refuse the same hole with the same sentence through the one function they already read; nothing is narrowed at a consumer. The widening half is the ruling made mechanical: one tokenizer and one judge for both dialects, so parity cannot drift by construction, and the 19-part table pins it. The in-app dialect is not narrowed (① item 5).

The boundary at text-field-html-outlets.pin.test.ts:284 — right, and pinned on both sides. The question "is this brace pair a hole" belongs to the path grammar (PATH_RE, unchanged by this card); "is this hole well formed" belongs to the formatter grammar (this card). Drawing the line at a path followed by | is judged; brace text whose part before any | is not a path stays verbatim means every brace pair that can be an author's formatter use is judged, while mail keeps its one legitimate verbatim case (a mail body may carry literal braces; the in-app engine refuses everything, having no such case). The residue — {{ | upper }}, {{ a b | upper }}, {{ | date-long }} — looks like a hole attempt but has no path, and it stays verbatim under the declared mail path grammar exactly as {{ rows[0].x }} does; moving it is a path-grammar decision, outside this card and named in ③. The new pin covers the literal set through the real renderer and the placement judge, and the refused set through both entry points, triple braces included, so the three readers agree by construction (one parseMailHole). The corpus edit (drop {{ x | two words }}, add {{ a b | upper }}) keeps the "same spans" case honest. Accepted.

Tried to break it. (a) A non-word key on FORMATTERS that would make parseHole return a hole where it returned null — none exists (① item 5). (b) A mail spelling accepted at the base and refused at the head — none (① item 6). (c) currency :@currency_code with the space — currencyCodePath reads the trimmed name before the colon, so the @path form survives the new tokenizer; tested. (d) A | inside a quoted argument (default:'a | b') — chainsASecondFormatter reads the unchanged formatterPart; unchanged. (e) {{ x | :EUR }} — name is empty, the sentence says "writes no formatter name after the |"; acceptable for an author who wrote :EUR. (f) ReDoS — splitHoleFormatter's one regex is two anchored single-character alternatives; WORD_RE is ^\w+$; linear. (g) The placement judge now listing refused holes — benign (① item 8). No must-fix.

Should-fix.

  • S1 (changesets and PR body: disclose the class, and the one widening). State that a formatter part the in-app dialect accepts now publishes and renders in mail even where mail refused it before — currency:"EUR" was refused by currency-code-literal at every door and at render, and now publishes — and that every double-quoted argument loses its quotes (date:"long", truncate:"80", number:"2", default:""), not only default:"n/a". Replace "No authoring door accepts anything it refused before" (formula) and "No accept set is widened" (lint) with the true sentence: the mail accept set widens to the in-app reading of the formatter part, per the ruling, and narrows by the non-word names. The level does not move (a widening under minor with the narrowing already declared), which is why this is not a must-fix; but the changeset is the release's input and a false accept-set sentence ships verbatim into the CHANGELOG, where only a dedicated docs PR can amend it later.
  • S2 (correct the unreleased mail templates: an unknown hole formatter (e.g. a misspelled curency) is refused nowhere and renders the raw value; the in-app dialect refuses it (found reviewing #26660) #26948 sentence in place). .changeset/26948-mail-unknown-formatter-formula.md still says text after the | that is not a single word "still names no formatter and is left as written". It is unreleased and ships in the same release as this entry; edit that sentence in that file (one line) instead of carrying a "Supersedes, in the same release" paragraph, so the compiled CHANGELOG never states a behaviour that was false on the day it was published.
  • S3 (one stale docs line on the page this PR edits). content/docs/automation/email-templates.mdx:155, "An unknown formatter falls back to the raw value rather than failing the render.", has been false since feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217 and now contradicts three rules on the same page, one of them added by this PR above it. Delete the line in this PR; the page is already in the diff.

Non-blocking observation: the header of mail-hole-grammar.ts says the two dialects "refuse the rest with the same sentence"; for formatter-shape and known-formatter the in-app dialect keeps its own sentence by design (pinned). "the same verdict" would be exact. Comment only.

Implemented-by: claude/project-thread-e0g649
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS


Generated by Claude Code

…rgument class in the changesets; drop the stale raw-fallback sentence

Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-28T14:52Z

Head 4bc8e16d39df2740f685b5aa0d82036ab7d2babb. Card #27225, claim 6469619404, dev report 6471194727.

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/l tests tooling

Projects

None yet

2 participants