Skip to content
Merged
20 changes: 20 additions & 0 deletions .changeset/26660-mail-hole-grammar-formula.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/formula': minor
---

feat(formula)!: a template hole takes one formatter, and the `currency` formatter's argument is a literal ISO 4217 code — both refused at compile, never rendered as formatter syntax or in US dollars

Clause-②: no (narrowing) — the in-app template dialect's compile (and so `os validate` and the publish gate for every template slot) refuses a hole with a second formatter (`{{ x | default:0 | number:2 }}`) and a `{{ x | currency:ARG }}` hole whose `ARG` is not three capital letters, both of which it accepted before; `formatValue('currency', value, ARG)` now throws a `RangeError` for such an `ARG`: a path or another argument `Intl` refuses, and an empty one, used to fall back to US dollars, and a lowercase code (`eur`), which `Intl` formatted in that currency, is now refused too. No accept set is widened; the additions are exports only: `judgeHoleFormatter`, `mailHolePattern`, `parseMailHole`, `judgeMailHoleGrammar`, and the types `TemplateHoleGrammarRefusal`, `TemplateHoleGrammarRule`, `ParsedMailHole` and `MailHoleGrammarFinding`.
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no export is removed: a chained formatter and a currency argument that never rendered as written are now refused where the template is compiled. The refusal names the hole and the rule it breaks, so there is nothing for a migration to rewrite. -->

**BREAKING** accept-set narrowing in the template dialect, and a new shared mail hole grammar.

- **Before, a chained hole.** `{{ record.n | default:0 | number:2 }}` compiled as `default` with the argument `0 | number:2`, and rendered that text.
- **Before, a currency path.** `{{ record.amount | currency:currency_code }}` compiled; `Intl` refused the argument and the formatter caught that and used US dollars, so the amount printed in a currency nobody wrote.
- **Now.** `templateEngine.compile` / `evaluate` answer a `parse` error naming the hole and the rule. `formatValue('currency', …)` throws a `RangeError` for an argument that is not a code, before it reads the value. A lowercase code (`currency:eur`), which formatted in euros before, is refused the same way. No argument still formats in US dollars.
- **Shared grammar.** `judgeHoleFormatter` is the judgement both template dialects use. The mail template grammar is exported once, `mailHolePattern`, `parseMailHole` and `judgeMailHoleGrammar`, and is what `@objectstack/plugin-email`'s renderer, `judgeMailHtmlHoles` and `@objectstack/lint` read; `judgeMailHtmlHoles` no longer keeps a copy of it.
- **Who sees it.** A template slot that chained formatters, or wrote a path or a lowercase code as the currency argument. No template shipped in this repository does.

**The remedy.** Keep one formatter per hole and decide anything more where the data is assembled. Write the currency code itself (`currency:EUR`), or format the amount where the data is assembled and pass the formatted text.
10 changes: 10 additions & 0 deletions .changeset/26660-mail-hole-grammar-messaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/service-messaging': patch
---

fix(service-messaging): the email and inbox channels grade a `TEMPLATE_INVALID` template failure permanent, like the other template failures

Clause-②: no
Metadata-protocol: none

`@objectstack/plugin-email`'s `sendTemplate` and `renderTemplate` now fail with `TEMPLATE_INVALID` for a stored template whose hole the mail grammar refuses (a second formatter, or a `currency` argument that is not a literal ISO 4217 code). Retrying cannot succeed until the template is republished, so `classifyError` on both channels grades it `permanent`, as it already grades `TEMPLATE_NOT_FOUND`, `TEMPLATE_INACTIVE`, `MISSING_VARIABLES` and `TEMPLATE_UNSUPPORTED`: the delivery goes dead at once with the code on its row, instead of spending the whole retry schedule first. Every other failure keeps its grade.
19 changes: 19 additions & 0 deletions .changeset/26660-mail-hole-grammar-plugin-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-email': minor
---

feat(plugin-email)!: a mail template hole that chains a second formatter, or whose `currency` argument is not a literal ISO 4217 code, fails the render with `TEMPLATE_INVALID` instead of printing formatter syntax or US dollars

Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes a hole with a second formatter, or `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, both of which rendered before. No accept set is widened, and the package adds no export.
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no export changes: the renderer refuses a hole the authoring rule refuses, for a template that reached it without being judged. There is nothing for a migration to rewrite. -->

**BREAKING** a render that printed a wrong currency now fails.

- **Before, a chained hole.** `{{ actual_amount | default:0 | number:2 }}` rendered `0 | number:2` for an empty value and `71100` otherwise, in every face.
- **Before, a currency path.** `{{ planned_amount | currency:currency_code }}` with `currency_code = 'eur'` rendered `US$1,234.50` (zh-CN), `$1,234.50` (en-US) and `1 234,50 $US` (fr-FR), in every face.
- **Now.** The render throws `TEMPLATE_INVALID: email template NAME (locale=LOCALE): …`, naming the hole and the rule it breaks (one formatter per hole, or the literal-only currency code); `sendTemplate` sends nothing. The authoring doors refuse the same hole first (`email-template-hole-grammar`), so this is reached only by a template stored before that rule, until its next publish.
- **The grammar.** The renderer parses holes with `@objectstack/formula`'s `parseMailHole`, the grammar the authoring rule and the placement judge read; it keeps no copy of its own.

**The remedy.** Republish the template with one formatter per hole and a literal currency code (`currency:EUR`), or format the amount in the caller and pass the formatted text as its own variable.
20 changes: 20 additions & 0 deletions .changeset/26660-mail-hole-grammar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': minor
---

feat(lint)!: a new gating authoring rule, `email-template-hole-grammar`, refuses an email template hole that chains a second formatter or whose `currency` argument is not a literal ISO 4217 code

Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse an `email_template` whose `subject`, `bodyText` or `bodyHtml` writes a hole with a second formatter (`{{ x | default:0 | number:2 }}`) or `{{ x | currency:ARG }}` with an `ARG` that is not three capital letters, both of which they accepted before. No accept set is widened; the additions are exports only: `validateEmailTemplateHoleGrammar`, `EMAIL_TEMPLATE_HOLE_GRAMMAR`, the type `EmailTemplateHoleGrammarFinding`, and the rule id `email-template-hole-grammar`.
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: a hole that never rendered as written (the renderer printed formatter syntax, or the amount in US dollars) is now refused where it is authored. The refusal names the hole and the rule it breaks, so there is no key for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at every authoring door for `email_template`.

- **Before, a chained hole.** `{{ actual_amount | default:0 | number:2 }}` passed every gate. The second `|` was read as part of `default`'s argument, so the hole printed the text `0 | number:2` for an empty value and the bare `71100` otherwise; `{{ x | upper | lower }}` stayed on the page as written.
- **Before, a currency path.** `{{ planned_amount | currency:currency_code }}` passed every gate. The argument is not read as a path, so the amount rendered in US dollars whatever the record's currency (`$1,234.50` for a record in euros).
- **Now.** The rule refuses both at `error`, naming the hole, the face and the template. A hole takes one formatter; anything more is decided in the caller that supplies the data. The currency argument is a literal code in three capital letters (`currency:EUR`), never a path. A lowercase or empty argument is refused the same way. `currency` with no argument is unchanged (US dollars).
- **Which holes.** The grammar is `@objectstack/formula`'s `judgeMailHoleGrammar`, the one the mail renderer parses with; all three faces are judged.
- **Who sees it.** A template that chained formatters, or wrote a path or a lowercase code as the currency argument. No template shipped in this repository does.

**The remedy.** Keep one formatter per hole and pass any further decided value (a default, a second format) as its own variable. Write the currency code itself (`currency:EUR`). To show each record's own currency, format the amount in the caller that supplies the data and pass the formatted text as its own variable.
25 changes: 22 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ never needs triple braces.
same way they do in-app. `datetime` honours the reference timezone the caller
passes; calendar-day `date` values are timezone-naive.

**One formatter per hole.** `{{ amount | default:0 | number:2 }}` is refused by
`os validate`, `os build`, `os lint` and the metadata publish door
(`email-template-hole-grammar`): a hole takes one formatter, and there is no
chaining. Decide the value in the caller that supplies the data — a default, a
second format — and pass the finished value as its own variable.

**The `currency` argument is a literal code.** `currency:EUR` formats in euros,
and `currency` with no argument formats in US dollars. The argument is an ISO
4217 code in three capital letters, never a path: `currency:currency_code` does
not read the record's `currency_code`. `os validate`, `os build`, `os lint` and
the metadata publish door refuse any other argument (`email-template-hole-grammar`,
a `422 INVALID_METADATA` at the publish door), naming the hole and the rule. To
show an amount in each record's own currency, format it in the caller that
supplies the data and pass the formatted text as its own variable.

**Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for
element text and for a quoted attribute value that holds plain text, so those
are the supported placements for `{{path}}`. A hole anywhere else — inside a
Expand All @@ -94,10 +109,14 @@ the `href` shape above. The refusal (`email-template-hole-placement`, a
supported ones. A template already stored keeps sending; it is judged the next
time it is published.

Two properties of the renderer to author around:
Three properties of the renderer to author around:

- **A missing placeholder renders as an empty string.** Rendering never throws.
Declare a variable `required` (below) if absence should be an error instead.
- **A missing placeholder renders as an empty string.** Declare a variable
`required` (below) if absence should be an error instead.
- **A hole the authoring rules refuse is refused at render too.** A template
stored before a rule existed is judged at its next publish; until then
`sendTemplate` and `renderTemplate` fail with `TEMPLATE_INVALID`, naming the
template and the hole, rather than send a wrong value.
- **There are no loops, conditionals or partials.** A template is a data-only
rendering by design; branching belongs in the caller, which passes in the
already-decided values.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (54)...
→ Running author-time rules (55)...
→ Checking that every required capability has a provider installable in this edition...
→ Collecting package docs (ADR-0046)... 0 collected
→ Writing artifact...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ A clean run walks the registry and reports timing:
Config: /path/to/support-desk/objectstack.config.ts
Load time: 21ms
→ Validating against ObjectStack Protocol...
→ Running author-time rules (54)...
→ Running author-time rules (55)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (54)...
→ Running author-time rules (55)...

✗ Author-time rules failed (1 issue)
• stack · action 'resolve_ticket' visible: bare reference `status` — a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (54)...
→ Running author-time rules (55)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
7 changes: 6 additions & 1 deletion packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ export type {
ParseCelToAstOptions,
} from './cel-engine';
export { cronEngine } from './cron-engine';
export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine';
export { templateEngine, TEMPLATE_FORMATTERS, formatValue, judgeHoleFormatter } from './template-engine';
export type { TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine';
// The mail template hole grammar — one copy, read by `@objectstack/plugin-email`'s
// renderer, the placement judge below and `@objectstack/lint`'s grammar rule.
export { mailHolePattern, parseMailHole, judgeMailHoleGrammar } from './mail-hole-grammar';
export type { ParsedMailHole, MailHoleGrammarFinding } from './mail-hole-grammar';
// #25189 — where a hole may sit in a mail template's HTML face (ruling
// 6335348115 on #25082, F1 A2 / F3 C2). Here because both readers already
// depend on this package and on nothing else they share: `@objectstack/lint`'s
Expand Down
111 changes: 111 additions & 0 deletions packages/formula/src/mail-hole-grammar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The mail template hole grammar — ONE copy, read by every reader of a mail
* template's holes:
*
* - `@objectstack/plugin-email`'s renderer (`template-engine.ts`), which
* substitutes the holes of every face (`subject`, `bodyText`, `bodyHtml`);
* - this package's placement judge (`mail-html-hole-placement.ts`), which
* classifies where each hole of `bodyHtml` sits;
* - `@objectstack/lint`'s `email-template-hole-grammar` authoring rule, which
* refuses a hole the grammar refuses at `os validate`, `os build`, `os lint`
* and the metadata publish door.
*
* A hole is `{{path}}`, `{{{path}}}` or either with one formatter from the
* shared whitelist, `{{ path | formatter[:arg] }}`. The path is a dotted path
* of word characters. Text between braces that is not a path is not a hole:
* the renderer leaves it as written.
*
* What follows the first `|` is judged by `judgeHoleFormatter`
* (`template-engine.ts`), the function the in-app template dialect judges its
* holes with too, so the two dialects refuse the same formatter parts with the
* same sentence: one formatter per hole, and a `currency` argument that is a
* literal ISO 4217 code. A refused hole is a hole — it is found, located and
* named — and the renderer refuses to render it rather than print a wrong
* value or formatter syntax.
*/

import { judgeHoleFormatter, type TemplateHoleGrammarRefusal } from './template-engine';

/** 1=open (`{{` or `{{{`), 2=brace-free inner body, 3=close (`}}` or `}}}`). */
const MAIL_HOLE_SOURCE = String.raw`(\{\{\{?)([^{}]*)(\}\}\}?)`;

/**
* A fresh global matcher for mail template holes. Capturing the inner body
* with `[^{}]*` (one star over a negated class, no nested quantifier) keeps
* the match linear — no polynomial-ReDoS shape; the body is then parsed with
* plain string operations ({@link parseMailHole}). A new instance per caller,
* so no two scans share a `lastIndex`.
*/
export function mailHolePattern(): RegExp {
return new RegExp(MAIL_HOLE_SOURCE, 'g');
}

// Validated against small, already-extracted strings (bounded input → no ReDoS).
const PATH_RE = /^[\w.]+$/;
const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/;

/** A mail hole's inner body, parsed. */
export interface ParsedMailHole {
path: string;
formatter?: string;
/** The formatter's argument, quotes removed; absent when none was written. */
arg?: string;
/** Set when the grammar refuses the hole: it is not rendered, and authoring refuses it. */
refusal?: TemplateHoleGrammarRefusal;
}

/**
* Parse a hole's inner body (the text between the braces). `null` when the
* body is not a path with an optional formatter — text the renderer leaves
* verbatim, and that no reader treats as a hole.
*/
export function parseMailHole(inner: string, hole = `{{${inner}}}`): ParsedMailHole | null {
const pipe = inner.indexOf('|');
if (pipe === -1) {
const path = inner.trim();
return PATH_RE.test(path) ? { path } : null;
}
const path = inner.slice(0, pipe).trim();
if (!PATH_RE.test(path)) return null;
const formatterPart = inner.slice(pipe + 1).trim();
const m = FILTER_RE.exec(formatterPart);
if (!m) {
// `{{ x | upper | lower }}` matches no single formatter shape, and the
// renderer used to leave it on the page as written: a chained hole is
// still a hole, refused by the one-formatter rule.
const name = formatterPart.split(/[:|]/, 1)[0].trim();
const refusal = judgeHoleFormatter(hole, formatterPart, name, undefined);
return refusal ? { path, formatter: name, refusal } : null;
}
const formatter = m[1];
const arg = m[2];
const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg);
return {
path,
formatter,
...(arg !== undefined ? { arg } : {}),
...(refusal ? { refusal } : {}),
};
}

/** One hole of a mail template face that the grammar refuses. */
export interface MailHoleGrammarFinding {
/** Offset of the hole's first brace in the face. */
start: number;
/** The hole as written, braces included. */
text: string;
refusal: TemplateHoleGrammarRefusal;
}

/** Every hole in one mail template face (`subject`, `bodyText` or `bodyHtml`) the grammar refuses, in order. */
export function judgeMailHoleGrammar(face: string): MailHoleGrammarFinding[] {
if (typeof face !== 'string' || face === '') return [];
const findings: MailHoleGrammarFinding[] = [];
for (const m of face.matchAll(mailHolePattern())) {
const parsed = parseMailHole(m[2], m[0]);
if (parsed?.refusal) findings.push({ start: m.index ?? 0, text: m[0], refusal: parsed.refusal });
}
return findings;
}
Loading
Loading