Skip to content

feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render - #26799

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-26660-mail-hole-grammar
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-26660-mail-hole-grammar

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #26673
Part of #26660 (item c, the silent USD; item b stays open on the card, see Acceptance notes)

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — the published skill (skills/objectstack-formula/SKILL.md, the template row) already spells a hole as one optional formatter, {{ path | formatter[:arg] }}; the refusals name both rules at every door, and content/docs/automation/email-templates.mdx is updated here.

维护者速读

  • 改了什么:邮件模板占位符只允许一个格式化器,currency 的参数只能是三位大写 ISO 4217 字面代码;两条规则在 os validate / os build / os lint 与元数据发布门(422 INVALID_METADATA)拒收,渲染时同样拒收(TEMPLATE_INVALID),应用内模板方言共用同一判定。
  • 为什么改:{{ x | default:0 | number:2 }} 会把 0 | number:2 原样发给收件人,{{ x | currency:currency_code }} 会静默按美元显示金额,所有门禁都放行(hotclm 实测)。
  • 风险与回滚:收紧(BREAKING),写了链式格式化器或路径/小写币种参数的模板会被拒;本仓库零命中。已存储的旧模板在下次发布前发送会失败而不是发错。回滚即还原本 PR。
  • 先看:packages/formula/src/template-engine.ts(judgeHoleFormatter 与 currency 格式化器)、packages/plugins/plugin-email/src/template-engine.ts(渲染时抛 TEMPLATE_INVALID)、packages/lint/src/validate-email-template-hole-grammar.ts。
  • 两分钟验证:pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2 src/template-hole-grammar.render.test.ts(先 build formula),预期 10 passed:链式与路径参数都以 TEMPLATE_INVALID 拒收,currency:EUR 渲染为 €1,234.50。
  • 需要你做的:无,席位落地。

What changes

One hole grammar, one judgement, every reader:

  • @objectstack/formula
    • judgeHoleFormatter(hole, formatterPart, formatter, arg) (in template-engine.ts, beside FORMATTERS) is the one judgement of a hole's formatter part, used by both template dialects. Two rules: one formatter per hole (a second | outside a closed quoted argument is refused), and the currency argument is a literal ISO 4217 code in three capitals (a path, a lowercase or an empty argument is refused). No argument still formats in USD.
    • The in-app dialect: templateEngine.compile / evaluate answer a parse error naming the hole and the rule, so validateExpression reports invalid-template at os validate and the publish gate.
    • The currency formatter no longer catches the Intl refusal and substitutes USD: a non-code argument throws a RangeError before the value is read.
    • The mail hole grammar now lives once, in mail-hole-grammar.ts (mailHolePattern, parseMailHole, judgeMailHoleGrammar). judgeMailHtmlHoles drops its copy and imports it; so does the renderer below. A chained hole is recognised as a hole even when its first formatter alone would not parse ({{ x | upper | lower }} used to be left on the page verbatim).
  • @objectstack/lint: a new gating rule, email-template-hole-grammar (validateEmailTemplateHoleGrammar), judges subject, bodyText and bodyHtml of every template on all three commands and on the runtime publish surface for email_template writes, refused there with the existing 422 INVALID_METADATA. The four CLI transcripts that print the rule count (54 to 55) are updated.
  • @objectstack/plugin-email: the renderer parses with the shared grammar; a refused hole throws TEMPLATE_INVALID, and resolveAndRenderTemplate names the template and locale in front of it. It is reached only by a template stored before the rule existed, until its next publish. It is now the one way a render throws.
  • @objectstack/service-messaging (patch round 1): the email and inbox channels' classifyError grade TEMPLATE_INVALID permanent, beside TEMPLATE_NOT_FOUND, TEMPLATE_INACTIVE, MISSING_VARIABLES and TEMPLATE_UNSUPPORTED. A pre-rule stored template that fails this way now goes dead at once with the code on its delivery row, instead of spending its retry ladder. Patch changeset 26660-mail-hole-grammar-messaging.md (Clause-②: no, Metadata-protocol: none).

Landing outside the named files, and why. The save-time verdict's producer is the authoring-rule registry in packages/lint (the email_template publish door is that registry's runtime surface), not email-template-door.ts, which is the closed sys_email_template row door and judges no holes. The door pin lives in packages/metadata-protocol (test only), beside the placement rule's.

Same-day churn. Both symptoms re-measured on origin/main a485b5a3 through the surviving by-name render path, EmailService.renderTemplate (the module-level body renderer is retired from the entry). currency:currency_code with eur rendered US$1,234.50 (zh-CN), $1,234.50 (en-US), 1 234,50 $US (fr-FR); the chained hole rendered 0 | number:2 for null and 71100 otherwise, in subject, text and html.

Sweep (one pass, all trees: packages, examples, skills, content, apps, ui, docs). Chained holes: zero hits (the one regex hit is a JSX style object). currency: arguments: all are uppercase literals (EUR, USD, one quoted 'USD'), all still admitted. No in-repo template needed a change.

Tests

  • packages/formula/src/template-hole-grammar.test.ts (new, 24): both rules through judgeHoleFormatter, the in-app compile / evaluate / validateExpression, the formatter backstop, and the mail grammar's parse and per-face judge.
  • packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts (new, 10): renderTemplate and sendTemplate refuse both shapes in each face with TEMPLATE_INVALID naming template, locale, hole and rule; nothing is sent; one formatter, a quoted | and currency:EUR still render.
  • packages/lint/src/validate-email-template-hole-grammar.test.ts (new, 15): the rule per face, the registry entry (gating, all commands, email_template runtime surface), each os command at error, the runtime gate.
  • packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts (+2): each refusal at the publish door asserts code INVALID_METADATA + status 422 + the issue's rule and path, with a non-vacuous admit.
Full suites and typecheck at 9fd3fde
  • @objectstack/formula: Test Files 55 passed (55), Tests 1476 passed (1476); typecheck exit 0.
  • @objectstack/plugin-email: Test Files 40 passed (40), Tests 712 passed (712); typecheck exit 0.
  • @objectstack/lint: Test Files 169 passed (169), Tests 7445 passed (7445); typecheck exit 0.
  • @objectstack/metadata-protocol: Test Files 269 passed, 3 skipped (272), Tests 21618 passed, 19 skipped; typecheck exit 0.
  • pnpm exec eslint on the 13 changed TypeScript files: exit 0.
  • 9d428aae changes four docs transcripts only (no source); the suites above were not re-run there, the transcript gate was.
Ablations (each through scripts/ablation-replace.mjs, WRAP mode; dist legs proven with ablation-dist-preflight)
  • A, currency rule off (formatter === 'currency' to a marker in judgeHoleFormatter; formula + lint rebuilt, marker present in 2 dist files): formula 11 failed, plugin-email 4 failed, lint 9 failed, metadata-protocol 1 failed — exactly the currency pins; every chain pin stayed green. A first attempt was refused by the tool before running anything (the replacement contained the anchor, so the count could not drop); re-anchored.
  • B, one-formatter rule off (marker on the chainsASecondFormatter branch; rebuilt, marker in dist): formula 6, plugin-email 4, lint 6, metadata-protocol 1 failed — exactly the chain pins.
  • C, renderer guard off (plugin-email source): plugin-email 8 failed, others green; the chained template rendered Paid 0 | number:2, the original defect.
  • D, the formatter's USD substitution put back (formula source): 1 failed, the formatter-throws pin.
  • Every leg restored to the HEAD blob with an empty git diff HEAD; after the last dist leg formula and lint were rebuilt and both markers proven absent from dist/, tree clean.
- `packages/services/service-messaging/src/email-channel.test.ts` and `inbox-channel.test.ts` (+1 each): a `TEMPLATE_INVALID: …` failure from `sendTemplate` / `renderTemplate` reaches the delivery row with the code at its front and is graded `permanent`. Ablated per channel (the word removed from the regex): each went red on exactly that pin, `expected 'retryable' to be 'permanent'`, and was restored to the HEAD blob.

Acceptance notes

Verification

Gate families (derived with dispatch-gates --commands, reconciled with --ran: 91 derived, 91 run)
  • 86 derived commands exit 0 at 9fd3fde3, plus pnpm check:error-code-casing and node scripts/check-platform-checklist.mjs --changed-from origin/main (both exit 0).
  • pnpm check:docs-transcript-drift: exit 1 at 9fd3fde3 (four transcripts printed 54 rules, the registry derives 55) — fixed in 9d428aae, exit 0.
  • pnpm check:cli-examples-parity: newly derived at 9d428aae, exit 0.
  • pnpm --filter @objectstack/spec run check:skill-examples and pnpm check:dual-build-cjs-loads: exit 3 (prerequisite: unbuilt packages) at first; exit 0 at 9d428aae once the dogfood closure was built.
  • node scripts/check-plugin-teardown-shape.mjs --self-test: exit 3, NOT MEASURED — its positive control is pinned to a commit this shallow checkout cannot reach; checker health only, CI runs it.
Dogfood battery at 9d428aa

pnpm --filter @objectstack/dogfood test under the verify lock (closure built first): VERDICT command-exit 0, Test Files 302 passed, 3 skipped (305), Tests 2507 passed, 20 skipped (2527).

Patch round 1 at dfe8026 (origin/main merged at 1cd4b73, the PR was 15 behind)
  • Full suites and typecheck exit 0: @objectstack/service-messaging Test Files 61 passed (61), Tests 731 passed; @objectstack/formula 56 files, 1484 tests; @objectstack/plugin-email 40 files, 712 tests; @objectstack/lint 169 files, 7445 tests; @objectstack/metadata-protocol 269 passed and 3 skipped files, 21618 passed and 19 skipped tests. pnpm exec eslint on the 17 changed TypeScript files: exit 0.
  • Gates re-derived with dispatch-gates --commands (no paths) at dfe8026: 93 commands (adds pnpm check:i18n and pnpm check:i18n-stale-fill). 92 exit 0 on the first run; pnpm check:dual-build-cjs-loads exit 3 (six packages unbuilt in the fresh worktree), exit 0 once they were built. Extras pnpm check:error-code-casing and node scripts/check-platform-checklist.mjs --changed-from origin/main: exit 0. --ran reconciliation with exit codes recorded: 93 derived, 93 run, 0 NOT-MEASURED.
  • Dogfood battery at dfe8026: VERDICT command-exit 0, Test Files 313 passed, 3 skipped (316), Tests 2588 passed, 20 skipped (2608).

Commits: one per member (9e59d3de the currency literal, 44ccaf92 one formatter per hole), merges of origin/main (9fd3fde3, 1cd4b739), 9d428aae the transcript counts, and patch round 1 (d6a898e3 the messaging grade, dfe80262 its changeset).

Session: session_01QSeKYmQXM3rHJcPnrEPk4B


Generated by Claude Code

…s a literal ISO 4217 code, refused at authoring and at render instead of formatting in USD

`{{ x | currency:currency_code }}` passed every gate and rendered the amount
in US dollars: the argument is not read as a path, Intl refused it, and the
formatter substituted USD. The argument is now a literal code in three
capital letters, judged once by `judgeHoleFormatter` in @objectstack/formula
for both template dialects:

- formula: the in-app template compile refuses the hole (`invalid-template`
  at os validate and the publish gate); `formatValue('currency', ...)` throws
  a RangeError instead of substituting USD. The mail hole grammar is exported
  once (`mailHolePattern`, `parseMailHole`, `judgeMailHoleGrammar`); the
  placement judge drops its copy.
- lint: a gating rule `email-template-hole-grammar` judges subject, bodyText
  and bodyHtml on every command and the email_template publish door
  (422 INVALID_METADATA).
- plugin-email: the renderer parses with the shared grammar and a refused
  hole fails the render with TEMPLATE_INVALID, naming the template and hole.

No argument still formats in USD.

Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B
Co-authored-by: Claude <noreply@anthropic.com>
…; a chained hole is refused at authoring and at render instead of printing formatter syntax

`{{ x | default:0 | number:2 }}` passed every gate: the second `|` was read
as part of `default`'s argument, so the hole printed `0 | number:2` for an
empty value and the bare value otherwise, and `{{ x | upper | lower }}` was
left on the page as written. `judgeHoleFormatter` now refuses a second
formatter outside a quoted argument, for both template dialects:

- formula: the in-app compile refuses it (`invalid-template`); the mail
  grammar recognises the chained hole even when its first formatter alone
  would not parse, so it is located and named, never left verbatim.
- lint: `email-template-hole-grammar` refuses it on every command and the
  email_template publish door (422 INVALID_METADATA), naming the
  one-formatter rule and the caller remedy.
- plugin-email: the render fails with TEMPLATE_INVALID.

Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B
Co-authored-by: Claude <noreply@anthropic.com>
…ate-hole-grammar registered

`check:docs-transcript-drift` derives the count from `authoringRulesFor()`;
the new gating rule moves it from 54 to 55 for build and validate.

Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/email-templates.mdx (via bodyHtml (literal, a string literal in FACES), bodyText (literal, a string literal in FACES), emailTemplates (literal, a string literal in validateEmailTemplateHoleGrammar), email_template (literal, a string literal in runtimeTypes))
  • content/docs/concepts/metadata-lifecycle.mdx (via email_template (literal, a string literal in runtimeTypes))
  • content/docs/deployment/environment-variables.mdx (via email_template (literal, a string literal in runtimeTypes))
  • content/docs/deployment/install-modes.mdx (via email_template (literal, a string literal in runtimeTypes))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES), email_template (literal, a string literal in runtimeTypes))
  • content/docs/getting-started/quick-start.mdx (via emailTemplates (literal, a string literal in validateEmailTemplateHoleGrammar))

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

  • content/docs/releases/v18/18-2.mdx (via email_template (literal, a string literal in runtimeTypes))

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/formula/src/index.ts, packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 14 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 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 — 23 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 aeb9cf0ddc3233ca0c2dbb2c05fc91534a6b13bc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 648b5552aeba2224a365c508effb98de0d3c34b2 — the merge of head 898b6a1fe1cf57c1663fb88b4229f853616e4ec6 into base aeb9cf0ddc3233ca0c2dbb2c05fc91534a6b13bc, 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 648b5552aeba2224a365c508effb98de0d3c34b2 && git checkout 648b5552aeba2224a365c508effb98de0d3c34b2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aeb9cf0ddc3233ca0c2dbb2c05fc91534a6b13bc 898b6a1fe1cf57c1663fb88b4229f853616e4ec6 && git checkout -B drift-repro aeb9cf0ddc3233ca0c2dbb2c05fc91534a6b13bc && git merge --no-ff 898b6a1fe1cf57c1663fb88b4229f853616e4ec6

node scripts/docs-audit/affected-docs.mjs --json aeb9cf0ddc3233ca0c2dbb2c05fc91534a6b13bc

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 27, 2026
… and inbox channels

The mail renderer now fails a stored template whose hole the grammar
refuses with TEMPLATE_INVALID. Graded retryable, such a delivery spent its
whole retry ladder before going dead; it now goes dead at once, like the
other template failures. Pinned once per channel, each ablated.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dfe802626637eacda4e689a2972aa3733b4276a3
Local-runs: none
Scope: packages/formula/src/index.ts, packages/formula/src/mail-hole-grammar.ts, packages/formula/src/mail-html-hole-placement.ts, packages/formula/src/template-engine.ts, packages/formula/src/template-hole-grammar.test.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/index.ts, packages/lint/src/validate-email-template-hole-grammar.test.ts, packages/lint/src/validate-email-template-hole-grammar.ts, packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts, packages/plugins/plugin-email/src/email-service.ts, packages/plugins/plugin-email/src/template-engine.ts, packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts, packages/services/service-messaging/src/email-channel.test.ts, packages/services/service-messaging/src/email-channel.ts, packages/services/service-messaging/src/inbox-channel.test.ts, packages/services/service-messaging/src/inbox-channel.ts + packages/formula/src/validate.ts, packages/lint/src/runtime-gate.ts, packages/lint/src/collection-entries.ts, packages/lint/src/validate-email-template-hole-placements.ts, packages/plugins/plugin-email/src/index.ts, packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts, packages/services/service-automation/src/builtin/template.ts, ui/packages/core/src/actions/actionCopyTemplate.ts, packages/spec/src/contracts/email-service.ts, packages/spec/src/ui/bulk-action.zod.ts, skills/objectstack-formula/SKILL.md, examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts

Face: the file list carries no schema and no governed path, so the recogniser reads the face off the changesets' Clause-②: no (narrowing) line (it refuses --paths here and names that line); the floor is every path of the diff outside the prose faces. Inputs: cards #26660 and #26673 with every comment (triage 6441491903 and 6442560667, claims 6445327635 and 6445353774, both os-dev-reports 6447779926 and 6449628305, the claim amendment 6447835110), the PR body and file list, the net diff against main at the merge-base, and the check-runs on the head. Four packages publish; .changeset/*, content/docs/** and the four CLI transcripts are read, not reviewed.

① Derived judgments

Accept-set changes the diff implies — each named RIGHT or WRONG against the tree.

  1. email_template subject / bodyText / bodyHtml: a hole with a second | outside a closed single-quoted argument is refused at os validate / build / lint and at the email_template runtime publish door (422 INVALID_METADATA), rule email-template-hole-grammar. RIGHT — the refusal triage 6442560667 asked for, naming the one-formatter rule and the caller remedy. A chain whose first formatter does not match the mail FILTER_RE ({{ x | upper | lower }}) used to be left on the page verbatim by the renderer; it is now a refused hole (parseMailHole judges the chain rule on !m), declared in the lint changeset. RIGHT.
  2. Same faces: currency:ARG with ARG not three capitals is refused — a path, a dotted path, an empty argument, EURO, a lowercase code, and a double-quoted code (the mail FILTER_RE never stripped double quotes, so currency:"EUR" hands "EUR" to the judge; before, Intl refused it and USD printed). RIGHT — triage 6441491903: "Refuse it at template save until it is supported… never a silent default currency". One arm goes beyond the cards' defect: Intl accepts a lowercase well-formed code (IsWellFormedCurrencyCode uppercases), so currency:eur as a literal rendered in euros before and is refused now. It is declared as "three capital letters" in all three changesets, the docs page and the refusal sentence, and one literal spelling of an ISO 4217 code is the right grammar; judged RIGHT, with the changeset wording finding in ②.
  3. The in-app template dialect refuses the same two shapes at compile (templateEngine.compile / evaluate; validateExpression('template', …) reports invalid-template, packages/formula/src/validate.ts:963). A narrowing beyond mail templates. Every in-repo caller of that compile path and every slot it serves, read at the head: packages/lint/src/validate-expressions.ts:2804 and :3554 and validate-action-copy-templates.ts:129 (action confirmText / successMessage on object, page and bulk actions; flow text slots), packages/services/service-automation/src/flow-registration-checks.ts:619 (registerFlow), service-automation/src/builtin/template.ts:122 (renderTextSlot for notify title / message — a hole that does not compile already throws FlowTextTemplateError, the pre-existing fail policy), ui/packages/core/src/actions/actionCopyTemplate.ts:93 (fillTemplateHoles — a refused sentence is shown as written, pre-existing fail-soft), plus Object.titleFormat and promptTemplate per skills/objectstack-formula/SKILL.md:444. Sweep of every tree at the head (packages, examples, skills, content, apps, ui, docs): zero chained holes (the one regex hit is a JSX style object, examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts:112); every currency: hole argument is EUR or 'USD' (docs, spec describe text, ui tests, plugin-email pin). No shipped template, example or test fixture is refused. RIGHT.
  4. formatValue('currency', value, arg) throws RangeError for a non-code arg instead of substituting USD, before the value is read (a non-numeric value with a bad argument used to print the raw string; now throws — pinned on purpose). Who calls it with a non-ISO argument today: the only non-test caller of formula's formatValue in the repository is the mail renderer (packages/plugins/plugin-email/src/template-engine.ts:128), which now judges the hole first; the in-app evaluate judges at compile; every formatValue in ui/ is a local function of that name, not the formula export. Nobody passes a non-ISO argument. RIGHT.
  5. EmailService.sendTemplate / renderTemplate throw TEMPLATE_INVALID: email template NAME (locale=L): … for a stored template the grammar refuses, and send nothing. One door: the only render call sites in email-service.ts are inside resolveAndRenderTemplate (:1472-1475), and renderTemplate / renderPlainTextTemplate are not on the package entry (index.ts:88-93), so the name-and-locale prefix is never missing. RIGHT.
  6. service-messaging email and inbox channels grade TEMPLATE_INVALID permanent (word-boundary match on the message's leading code). RIGHT — the ruling-A half the seat authorized in 6447835110.
  7. Unchanged, declared: currency with no argument still formats in USD; other formatter arguments stay unjudged; an unknown formatter still renders the raw value.

Public-surface changes (additive): @objectstack/formula exports judgeHoleFormatter, mailHolePattern, parseMailHole, judgeMailHoleGrammar and the types TemplateHoleGrammarRefusal, TemplateHoleGrammarRule, ParsedMailHole, MailHoleGrammarFinding; @objectstack/lint exports validateEmailTemplateHoleGrammar, EMAIL_TEMPLATE_HOLE_GRAMMAR, EmailTemplateHoleGrammarFinding and publishes the rule id email-template-hole-grammar (AUTHORING_RULES 54 → 55, the four transcripts match origin/main's 54 → the head's 55). RIGHT.

Exactly one mail hole grammar. packages/formula/src/mail-hole-grammar.ts holds the pattern and FILTER_RE (byte-identical to the renderer's former regex); plugin-email/src/template-engine.ts and formula/src/mail-html-hole-placement.ts dropped their copies and import it; packages/lint/src keeps none (its \{\{ regexes in lint-flow-patterns.ts and validate-flow-template-paths.ts are the in-app flow dialect's, not mail); the plugin-email pin text-field-html-outlets.pin.test.ts reads judgeMailHtmlHoles from formula. The in-app dialect keeps its own HOLE_RE / PATH_ONLY_RE — two dialects by design, one shared judgement (judgeHoleFormatter), no cycle (mail-hole-grammar.ts imports template-engine.ts, never the reverse).

② Semver level

Four changesets, read against what the diff publishes:

  • @objectstack/formula minor — Clause-②: no (narrowing), Metadata-protocol: narrowed, BREAKING banner, adr-0087: not-required (no-migration-prescription). RIGHT: the in-app hole grammar is spec-documented (packages/spec/src/ui/bulk-action.zod.ts:470 spells {{ amount | currency:EUR }} and formatter[:arg]), so a refused value is narrowed, and narrowed takes minor on a package the diff moves.
  • @objectstack/plugin-email minor — no (narrowing), narrowed, BREAKING, same marker. RIGHT (a stored value the renderer now refuses).
  • @objectstack/lint minor — no (narrowing), narrowed, BREAKING, same marker. RIGHT. Observation: the precedent of this exact shape, the placement rule (packages/formula/CHANGELOG.md:136-143), declared Metadata-protocol: none and shipped patch; this PR's narrowed follows the AGENTS.md definition ("a value refused or a requirement tightened") and takes the higher level. Not a finding.
  • @objectstack/service-messaging patch — Clause-②: no, Metadata-protocol: none. RIGHT: a grade change only.
  • Check Changeset and the ADR-0087 registration gate are green on the head; the PR body carries the same two lines.

Two should-fix findings on changeset prose (level unaffected):

  • S1 — .changeset/26660-mail-hole-grammar-formula.md:13 says formatValue('currency', value, ARG) "throws a RangeError for such an ARG instead of formatting in US dollars". True for a path or EURO; false for the lowercase arm, which Intl formatted correctly before (see ① item 2). The narrowing is declared; its before-account is wrong for one of the arms it names. Reword the sentence (the lint changeset's "A lowercase or empty argument is refused the same way" is the accurate shape).
  • S2 — all three narrowing changesets end their Clause-② line with "Nothing is widened". Read as "no accept set is widened" that is true; the diff does add the exports listed in ①, which minor already carries. Either name them as additive in the body or spell the line yes (narrowing), the arm the reader defines for "a diff that widens one surface and narrows another". The precedent (lint: an error-tier authoring rule refuses an email_template body hole the renderer cannot encode for its placement (#25082's email half, ruling F1 A2) #25189) used no (narrowing) for the same shape and both gates accept it, so this is wording, not a FAIL.

③ Boundary flags

Every dev flag, deviation and open_questions entry, answered:

④ Design judgment

Per floor file, against the code it cites and the scenario it is accepted against (cards #26660 item (c) and #26673: hotclm's {{ planned_amount | currency:currency_code }} with currency_code = 'eur', and {{ actual_amount | default:0 | number:2 }}, in subject, text and html), read in the tree, tried to break:

  • packages/formula/src/template-engine.ts — judgeHoleFormatter probed by hand: default:0 | number:2 (colon arm, argument carries |) refused; upper | lower (no colon) refused; default:'a | b' admitted; default:'a' | upper refused (text after the closing quote); an unclosed quote default:'a | b refused; number:2|upper refused; currency:'USD' and currency: EUR admitted; currency:, currency:eur, currency:EURO, currency:record.currency_code refused. The formatter and the judge share isCurrencyCode, so a hole the judge admits never throws at the formatter, and a well-formed three-capital non-currency (XYZ) is well-formed for Intl too — no throw. In the in-app parseHole the one-formatter refusal is returned before the FORMATTERS[name] lookup, so upper | lower gets the rule's sentence rather than "unknown formatter"; compileTemplate answers kind: 'parse', which validate.ts turns into invalid-template; evaluate compiles first, so the formatter throw is a backstop only. Holds.
  • packages/formula/src/mail-hole-grammar.ts — parseMailHole returns null for non-path text exactly as the renderer's old parseHole did (verbatim, unchanged); on !m it judges only the chain rule (name from a split on [:|]), so {{ x | foo-bar }} stays verbatim as before; mailHolePattern() is a fresh RegExp per caller — no shared lastIndex between the renderer's replace, the judge's matchAll and the rule. judgeMailHoleGrammar returns offset, text and refusal in face order. Holds.
  • packages/formula/src/mail-html-hole-placement.ts — copy removed; judgeMailHtmlHoles now also sees a chained hole whose first formatter did not parse (it used to skip it), consistent with the renderer that now refuses it; such a hole in an unsupported placement draws the placement finding as well, both error. The plugin-email pin holds the judge's spans to the renderer's. Holds.
  • packages/formula/src/index.ts — the exports in ①; no cycle. Holds.
  • packages/formula/src/template-hole-grammar.test.ts — both rules through the judge, each dialect's door, the formatter backstop (throws for null too), and the mail grammar's parse and per-face judge; every refusal pinned on its rule and sentence beside a non-vacuous admit. Holds.
  • packages/lint/src/validate-email-template-hole-grammar.ts and authoring-rules.ts — the same shape as the placement rule: collectionEntries (array and name-keyed), tier: 'gating', commands: ALL, surfaces: CLI_AND_RUNTIME, runtimeTypes: ['email_template']; all three faces judged with the renderer's own pattern (the renderer applies mailHolePattern() to every face, so the rule's holes are the renderer's holes); finding path emailTemplates[i].face, message names the face, the hole and the offset. Holds.
  • packages/lint/src/index.ts — the rule id is on the barrel for --json consumers. Holds.
  • packages/lint/src/validate-email-template-hole-grammar.test.ts — the rule per face, the registry entry, each os command at error, the runtime gate on a written template. Holds.
  • packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts — through the shipped evaluateRuntimeAuthoringGate, asserting code + status + issues + rulesRun, each with a non-vacuous admit. Holds.
  • packages/plugins/plugin-email/src/template-engine.ts — copy removed; the refusal is thrown before any lookup or format, so neither formatter syntax nor a substituted currency can reach a recipient; unknown formatter still raw; requireVars untouched. Holds.
  • packages/plugins/plugin-email/src/email-service.ts — the wrap rethrows every non-TEMPLATE_INVALID error unchanged (the MISSING_VARIABLES path above it is untouched) and prefixes the template and the resolved row's locale. Holds.
  • packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts — the cards' two bodies through renderTemplate and sendTemplate, every face, for null and 71100, refused with the prefix and the rule; nothing sent; the admits render €1,234.50 and $1,234.50. Holds.
  • packages/services/service-messaging/src/email-channel.ts, inbox-channel.ts and their tests — the word joins the permanent vocabulary; one pin per channel reads the code off the delivery row and asserts permanent. Holds.

No must-fix. Should-fix: S1 and S2 (changeset wording, ②). Observation only: the renderer's renderTemplate docblock still says "Missing placeholders render as empty strings (no throw)" — still true for a missing placeholder; the module header carries the refusal case.

Implemented-by: claude/issue-26660-mail-hole-grammar
Reviewed-by: session_01QSeKYmQXM3rHJcPnrEPk4B

VERDICT: PASS


Generated by Claude Code

…, and name what is added instead of "nothing is widened"

A lowercase code formatted in that currency before and is refused now; a
path or empty argument fell back to US dollars. The narrowing changesets
name their additive exports (plugin-email adds none).

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

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 3 · session_01QSeKYmQXM3rHJcPnrEPk4B · 2026-10-27T18:28Z

Head: 898b6a1fe1cf57c1663fb88b4229f853616e4ec6

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 size/xl tests tooling

Projects

None yet

2 participants