Skip to content

feat(spec, formula, plugin-email, lint): a mail template formats an amount in the record's own currency with currency:@path - #26937

Merged
os-zhuang merged 3 commits into
mainfrom
claude/project-thread-wonj87
Oct 28, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/project-thread-wonj87

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #26660

Item (c) of the card landed in #26799. This PR lands item (b): a mail template formats an amount in the record's own currency, with the code read from the data.

Clause-②: yes
Metadata-protocol: added
Skills-docs: follow-up #26940
ADR-0087: not-required — nothing stored changes meaning: currency:@path was refused at save and at render until now, so no stored template or built artifact carries it, and every form accepted before renders exactly as before.

维护者速读

  • 改了什么: 邮件模板新增 {{ 金额 | currency:@currency_code }} 写法:币种代码从发送数据里读,一个模板就能按每条记录自己的币种显示金额;站内模板(流程通知、按钮文案)对这个写法按名拒收。
  • 为什么改: hotclm 这类按合同发提醒的应用,金额币种各不相同;此前只能写死一个币种或只显示裸数字。
  • 风险与回滚: 只放宽了一个以前被拒的写法,旧写法与旧拒收全部不变;数据里没有合法代码(缺失、null、小写)时渲染报 TEMPLATE_INVALID,绝不用美元顶替。回滚即 revert 本 PR。
  • 先看: packages/formula/src/template-engine.ts(@ 标记与方言判定)、packages/plugins/plugin-email/src/template-engine.ts(取值与拒收)、packages/formula/src/mail-hole-grammar.ts(声明变量检查)。
  • 两分钟验证: pnpm --filter @objectstack/plugin-email exec vitest run src/template-currency-code-path.render.test.ts,预期 12 个测试全过:EUR/CNY 两条记录在 en-US、zh-CN 两个语言行各自按本币显示,三种坏代码均报 TEMPLATE_INVALID。
  • 需要你做的: 本 PR 改了 skills/objectstack-formula/SKILL.md(受管面 Tier H),需要维护者批准后由席位落地。

What changes

The rule, in one sentence (CURRENCY_ARGUMENT_RULE in @objectstack/formula; the spec's email_template face descriptions, the lint rule's hint and every renderer refusal quote it): A currency argument is either a literal ISO 4217 code in three capital letters (currency:EUR) or, in a mail template only, @ followed by a path into the send's data whose value is such a code (currency:@currency_code).

The @ mark keeps the path form apart from the literal for every input: a literal code never starts with @, and no hole path does. So every shape refused today stays refused, including the #26799 pins currency:currency_code and currency:record.currency_code.

  • @objectstack/formula (minor)
    • currencyCodePath(formatterPart) reads the marked form: @ then a dotted path of word characters with no empty segment, unquoted, nothing between the mark and the path.
    • judgeHoleFormatter takes an optional dialect ('in-app' by default, 'mail'). The mail dialect admits the marked form. The in-app dialect refuses it by name with the new rule currency-code-path-mail-only at compile and evaluate, so flow text slots, action and bulk-action copy, titleFormat and promptTemplate all refuse it (they refused it before as currency-code-literal).
    • parseMailHole carries the path as codePath. judgeMailHoleGrammar(face, { variables }) refuses a marked path no declared variable names (itself, or a root it lies under), when the template declares any: rule currency-code-path-undeclared.
    • currencyCodeValueRefusal(hole, path, value) names a resolved value that is not an ISO 4217 code in three capitals exactly as stored.
    • formatValue('currency', …) is unchanged: it never reads a path, throws for a non-code argument, and formats in USD with no argument.
  • @objectstack/plugin-email (minor): the renderer resolves the path against the send's data and formats in that code, in all three faces, double- or triple-braced. A missing, null, lowercase or other value throws TEMPLATE_INVALID: email template NAME (locale=L): …, naming the hole, the path and the value; nothing is sent. email-service.ts changes a comment only: its existing wrap already prefixes the template and locale.
  • @objectstack/lint (minor): email-template-hole-grammar passes the template's declared variables to the judge, and its hint quotes the rule. os validate, os build, os lint and the email_template publish door accept a well-formed marked path and refuse an undeclared one (422 INVALID_METADATA at the door).
  • @objectstack/spec (minor, descriptions only): EmailTemplateDefinitionSchema subject, bodyHtml and bodyText state the mail hole grammar (at most one formatter per hole, a second chained formatter refused) and the rule; variables[].name states the declared-path check. The descriptions promise no refusal of an unknown formatter name, because none exists today (see Acceptance notes). Reference docs regenerated (content/docs/references/system/email-template.mdx).
  • Docs and skill: content/docs/automation/email-templates.mdx teaches the form, the render refusal, the save-time checks and the in-app refusal. skills/objectstack-formula/SKILL.md gains two lines (below).

Which dialects: the mail dialect accepts currency:@path. The in-app dialect refuses it by name, because the doors that judge in-app text (closedScopeTemplateRefusal, the lint flow-path rules) check a hole's path against the roots a slot binds and never read an argument; accepting it there would let an argument's root reach the page unjudged. The three in-app spec describes (flow-text-slot-template.ts, action-copy-template.ts, bulk-action.zod.ts) stay true as written (they spell currency:EUR and formatter[:arg]), so they are unchanged.

Why

Measured at 6090bb73 (post-#26799) through EmailService.renderTemplate and the lint rule: {{ planned_amount | currency:currency_code }} is refused at save (1 finding, email-template-hole-grammar) and at render (TEMPLATE_INVALID … gives the currency formatter the argument 'currency_code') for both currency_code: 'eur' and 'EUR'. So no mail template could show an amount in the record's own currency. Data roots: a mail template's holes read the send's data with bare roots (planned_amount, contract.name); the in-app engine's roots are record, previous, input, user / current_user / ctx, os plus each slot's extra (flow variables; action record / params / result; bulk count and params). A root-spelled argument (currency:record.currency_code) has no meaning in mail and is refused by an existing pin, and a bare path is ambiguous against the literal; a marked form fits both dialects.

Tests

  • packages/formula/src/template-currency-code-path.test.ts (new, 38): path reading (3 accepted, 10 rejected shapes); mail dialect admits and parses; malformed marks refused as currency-code-literal quoting the rule; declared-variable check (refused, exact, root, none declared, literal not judged); value refusal for missing / null / eur / ' EUR' / number / object; in-app compile, validateExpression (invalid-template) and evaluate refuse by name; the formatter never reads a path; the spec's three face descriptions and the variable name description quote the rule (parity pin against the built spec).
  • packages/plugins/plugin-email/src/template-currency-code-path.render.test.ts (new, 12): one bundle with en-US and zh-CN rows, records in EUR and CNY: €1,234.50 / CN¥1,234.50 (en-US) and €1,234.50 / ¥1,234.50 (zh-CN) in every face; sendTemplate delivers two records in their own currencies; an unresolvable path, a lowercase code and a null code are each refused in both locales with the TEMPLATE_INVALID: email template contract.overdue (locale=L) prefix, the hole and the value, no $ in the message, nothing sent; the literal form and the bare-path refusal unchanged.
  • packages/lint/src/validate-email-template-hole-grammar.currency-path.test.ts (new, 13): accepted with no variables and with declared path or root; undeclared refused per face; malformed marks refused; hint quotes the rule; each os command; the runtime publish gate accepts and refuses.
  • packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts (+1): through evaluateRuntimeAuthoringGate, an undeclared marked path is refused with code INVALID_METADATA, status 422 and the issue; the declared path and a no-variables template publish.
  • The feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render #26799 pins (template-hole-grammar.test.ts, template-hole-grammar.render.test.ts, validate-email-template-hole-grammar.test.ts, the door test's existing cases) are unchanged and green.

Acceptance notes

  • currency with no argument still formats in USD (not changed here, per the dispatch). Census at 6090bb73 of a no-argument | currency hole, generated files, CHANGELOGs and release notes excluded: examples 0, seeds 0, skills 0, apps 0; docs 2 (content/docs/automation/flows.mdx:472, content/docs/data-modeling/formulas.mdx:79, both in-app); spec describes and comments 4 (flow-text-slot-template.ts:23, io-node-config.zod.ts:161, :632, :686); service-automation 2 (builtin/notify-node.ts:810, builtin/template.ts:104); refusal and migration prose that spells it as an example 2 (formula/src/template-engine.ts, the invalid-hole message; spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts:24); tests pinning the USD output 4 (formula/src/template-formatters.test.ts:19, formula/src/template-hole-grammar.test.ts:114, plugin-email/src/template-engine.test.ts:79, plugin-email/src/template-hole-grammar.render.test.ts:110), plus fixtures that only carry the hole (lint/src/validate-email-template-hole-grammar.test.ts:28, lint/src/validate-expressions.text-slot.test.ts:122, ui/packages/app-shell/.../flowTextSlots.doubleBrace-11824.test.tsx:122). No shipped template relies on it. Seat ruling: a separate narrowing card refuses a no-argument currency at authoring, filed as spec(formula): a currency hole with no argument still formats in US dollars — refuse it at authoring in every template dialect (follow-up of #26660) #26939 with this census.
  • Eval owed, filed as skills(objectstack-formula): no eval exercises currency:@path for a mail amount in the record's own currency (follow-up owed by #26660) #26940 (not in this PR): skills/objectstack-formula/evals/cel-predicates-formulas.json should ask for an email_template that shows an overdue amount in each contract's own currency, and expect {{ amount | currency:@currency_code }} (not a bare path, not a hard-coded code), the code stored in capitals, and the path declared when the template declares variables.
  • An unknown mail formatter is refused nowhere today: {{ x | nosuch }} renders raw (plugin-email/src/template-engine.ts). Not fixed here; filed 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. The spec descriptions were reworded in this PR so they do not promise that refusal.
  • In-app reading of a code from data is not built: it would need the closed-scope root judges to read an argument's path. Noted, not filed.
  • Skills, governed: skills/objectstack-formula/SKILL.md makes this PR Tier H (maintainer approval). File 471 → 473 lines, 5627 → 5668 tokens (ceiling 5997, headroom 329 after); the shipped SKILL.md set 4606 → 4608 lines; bundle total 213518 → 213559 tokens. The two lines are the only place a skill teaches a currency argument.

Verification

Suites and typecheck at 511e09b (the head: d1fffa1 plus a merge of origin/main 5ce3404)

Main moved packages/spec, packages/lint and packages/metadata-protocol (no file this PR touches), so after the merge (scripts/pm/os-regen-merge.sh, no conflict) the tree was rebuilt (pnpm build), check:generated reported every spec artifact current, and the six suites ran again. Under os-verify-lock, one && chain, VERDICT command-exit 0:

  • @objectstack/spec: Test Files 738 passed (738), Tests 22101 passed, 1 todo
  • @objectstack/formula: 57 passed (57), 1522 passed
  • @objectstack/plugin-email: 41 passed (41), 724 passed
  • @objectstack/lint: 170 passed (170), 7483 passed
  • @objectstack/metadata-protocol: 273 passed, 4 skipped (277); 21694 passed, 26 skipped
  • @objectstack/service-messaging: 61 passed (61), 734 passed
  • typecheck for all six: exit 0.

At d1fffa1 (before the merge) the same six were green too (spec 736 files / 22050 tests, lint 7479, metadata-protocol 271 files / 21669), and pnpm exec eslint --no-inline-config on the 11 changed .ts files exited 0.

Ablations (each restored to the HEAD blob, git diff HEAD empty)

Run with scripts/ablation-replace.mjs; every mutation landed (anchor 1 → 0, blob changed) and was restored by blob hash.

  • Renderer value guard dropped (plugin-email/src/template-engine.ts, the suite reads source): 4 failed | 8 passed, exactly the refusal pins. The unresolvable-path case then formatted in USD, the defect the guard exists to stop.
  • In-app dialect check disabled (formula/src/template-engine.ts): 2 failed | 36 passed, the two in-app refusal pins.
  • Declared-variable check disabled (formula/src/mail-hole-grammar.ts): 1 failed | 37 passed, the undeclared pin. Formula's own suite reads source; the lint and door legs read formula's dist and were not ablated.
Reproduction on main

At 6090bb73: render with currency_code: 'eur' and 'EUR' → TEMPLATE_INVALID: email template contract.overdue (locale=en-US): the hole {{ planned_amount | currency:currency_code }} gives the currency formatter the argument …; validateEmailTemplateHoleGrammar → 1 finding, email-template-hole-grammar.

Gate families
  • Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at 511e09b: 114 commands, run serially, exit codes captured before any pipe: 114 exit 0. --ran reconciliation: "114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED".
  • At d1fffa1 the same 114 ran: 112 exit 0, and pnpm --filter @objectstack/spec run check:skill-examples and pnpm check:dual-build-cjs-loads exited 3 (unbuilt packages); after pnpm build both exited 0 (267 prose examples type-check).
  • Extras at d1fffa1: pnpm check:dev-prereqs exit 0, pnpm check:skill-frame-freshness exit 0, node scripts/check-platform-checklist.mjs --changed-from origin/main exit 0, pnpm check:error-code-casing exit 0. node scripts/check-skills-token-ratchet.mjs exit 0 (the formula skill at 5668 of 5997).
  • Not run locally: the full dogfood battery (no ! in the title, no (narrowing) arm); CI's required contexts run the rest.
Review round at 49258de (contract review 6455368454: S1, S2)

One commit on top of 511e09b, text only: the email_template face descriptions now say "at most one formatter per hole (a second, chained formatter is refused)" instead of naming a formatter whitelist no door enforces (S2; the unknown-formatter gap is #26948), and the email-template-hole-grammar comment in packages/lint/src/authoring-rules.ts names the marked @path form and the undeclared-path check instead of a literal-only rule (S1). Reference docs regenerated (check:generated --fix, exit 0).

  • pnpm build under os-verify-lock: VERDICT command-exit 0, 116 of 116 tasks.
  • The six suites and their typechecks under os-verify-lock, one loop that exits on the first failure (|| exit 1), exit 0: spec 738 files / 22101 passed, 1 todo; formula 57 / 1522; plugin-email 41 / 724; lint 170 / 7483; metadata-protocol 273 passed, 4 skipped / 21694 passed, 26 skipped; service-messaging 61 / 734.
  • dispatch-gates --commands re-derived at 49258de: 114 commands, 114 exit 0; --ran: "114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED". node scripts/check-skills-token-ratchet.mjs exit 0.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h


Generated by Claude Code

…in the record's own currency with currency:@path

A `currency` argument is either a literal ISO 4217 code in three capital
letters (`currency:EUR`) or, in a mail template only, `@` followed by a path
into the send's data whose value is such a code (`currency:@currency_code`).
The `@` mark keeps the two forms apart for every input, so every refusal of a
bare path stays as it was.

- formula: the mail grammar admits the marked form (`codePath` on the parsed
  hole) and, given the template's declared variables, refuses a path none of
  them declares; the in-app dialect refuses the form by name
  (`currency-code-path-mail-only`); `CURRENCY_ARGUMENT_RULE` is the one
  sentence every reader quotes.
- plugin-email: the renderer resolves the path and refuses a value that is not
  a code (missing, null, lowercase) with TEMPLATE_INVALID, never a default
  currency.
- lint: email-template-hole-grammar passes the template's declared variables.
- spec: the email_template face descriptions state the grammar.

Claude-Session: https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h
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/spec, touching 23 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/formula/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.

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

  • content/docs/automation/email-templates.mdx (via EmailTemplateDefinitionSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/formula/src/index.ts) — pages documenting those are invisible to this run
  • 2 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 — 149 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 912076f8854b96093e11480f369456100f2de3d0 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 912076f8854b96093e11480f369456100f2de3d0

⚠️ 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 912076f8854b96093e11480f369456100f2de3d0 → 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: 511e09b12f88b60f995cb0483a1b27c0d4b68339
Local-runs: none
Scope: packages/spec/src/system/email-template.zod.ts, skills/objectstack-formula/SKILL.md, packages/formula/src/index.ts, packages/formula/src/mail-hole-grammar.ts, packages/formula/src/template-engine.ts, packages/formula/src/template-currency-code-path.test.ts, packages/lint/src/validate-email-template-hole-grammar.ts, packages/lint/src/validate-email-template-hole-grammar.currency-path.test.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-currency-code-path.render.test.ts + packages/formula/src/validate.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/runtime-gate.ts, packages/lint/src/validate-expressions.ts, packages/lint/src/validate-action-copy-templates.ts, packages/services/service-automation/src/flow-registration-checks.ts, packages/services/service-automation/src/builtin/template.ts, ui/packages/core/src/actions/actionCopyTemplate.ts, packages/spec/src/automation/flow-text-slot-template.ts, packages/spec/src/ui/action-copy-template.ts, packages/spec/src/ui/bulk-action.zod.ts, docs/adr/0032-unified-expression-layer.md, scripts/check-skills-token-ratchet.mjs, content/docs/automation/flows.mdx, content/docs/data-modeling/formulas.mdx

Face: the file list carries a published schema (packages/spec/src/system/email-template.zod.ts) and a governed path (skills/objectstack-formula/SKILL.md, Tier H), so the record is at CONTRACT_REVIEW_TIER on the schema face; the floor is every path of the diff outside the prose faces, and the skills/** hunk is reviewed sentence by sentence against the code. Inputs: card #26660 (body and every comment: triage 6441491903, the server seat's claim 6445327635, reports 6447779926 / 6449628305 / 6450298695, amendment 6447835110, ACCEPT 6450367555, release 6451239827, retriage 6452088840, the spec seat's claim 6452747733, the dev's report 6455019677); PR #26799 (merged, dabe77182) and its contract review 6450017524; PR #26937's body, file list, the one bot comment, and the net diff against main at the head (18 files, +779 / −50, two commits on the merge-base 5ce3404a: d1fffa10 the feature, 511e09b1 a merge of origin/main); the check-runs on 511e09b12 read four times (the readings are declared in ③). .changeset/* and content/docs/** read sentence by sentence, not reviewed as code. Local repository reads were git show / git grep on a ref fetched for this review; nothing was built, run or re-run.

① Derived judgments

Accept-set changes the diff implies, each named RIGHT or WRONG against the tree at the head.

  1. email_template subject / bodyText / bodyHtml admit currency:@path — @, then a dotted path of word characters with no empty segment, unquoted, nothing between the mark and the path. Unambiguous against the literal for every input: the literal is CURRENCY_CODE_RE = /^[A-Z]{3}$/ (template-engine.ts:191) and the path form is CURRENCY_CODE_PATH_RE = /^@(\w+(?:\.\w+)*)$/ (:217); the first can never match a string that begins with @, the second can never match one that does not, so the two accept sets are disjoint and no third shape is admitted (currency:currency_code, currency:record.currency_code, currency:eur, currency:, currency:'@x', currency:@, currency:@ x, currency:@a..b all stay currency-code-literal). The mail parser's FILTER_RE (mail-hole-grammar.ts:57) hands @currency_code as arg; the judge (template-engine.ts:312-315) tests isCurrencyCode(arg) first, then currencyCodePath(formatterPart) on the raw text, so quotes defeat the mark (currency:'@x' is refused) and currency: @x (space after the colon, which FILTER_RE and currencyCodePath both trim) is admitted in both readers alike. The rule is one sentence, said one way: CURRENCY_ARGUMENT_RULE (template-engine.ts:178-181) is quoted verbatim by the spec's MAIL_TEMPLATE_HOLES (email-template.zod.ts:107-111, pinned word for word against the built spec in template-currency-code-path.test.ts:487-490), by the lint hint (validate-email-template-hole-grammar.ts:90-91), and by every refusal sentence the renderer can throw (currencyArgumentRefusal mail arm :200-207, currencyCodeValueRefusal :256-264, currency-code-path-undeclared mail-hole-grammar.ts:155-159). RIGHT.

  2. The @path is resolved against the mail template's own data roots, exactly as a {{ path }} hole is. plugin-email/src/template-engine.ts:137 resolves parsed.codePath with the same lookup(data, …) that :130 resolves parsed.path with, over the same data (defaultTemplateContext spread under input.data, email-service.ts:1418-1421), with the same dotted-path semantics (lookup :62-71). All three faces go through the one render function (renderTemplate / renderPlainTextTemplate :93-114; email-service.ts:1474-1478), double- or triple-braced (the mail pattern's group 2 is the inner body either way). No root-spelled argument (record.) exists in mail, and the bare path stays refused, so nothing but the send's data can be read. RIGHT.

  3. One grammar: every in-app dialect refuses the marked form at save, by name. judgeHoleFormatter's fifth argument defaults to 'in-app' (template-engine.ts:300); only mail-hole-grammar.ts:95,100 pass 'mail'. The in-app parseHole (:418) passes no dialect, so currency:@record.currency_code yields currency-code-path-mail-only (:316-326), compileTemplate answers kind: 'parse' (:449-451), and validate.ts:963 turns that into invalid-template. Every save door of the in-app dialect reaches that compile: packages/lint/src/validate-expressions.ts:2804 (flow text slots) and :3554, validate-action-copy-templates.ts:129 (action and bulk-action confirmText / successMessage), service-automation/src/flow-registration-checks.ts:619 (registerFlow); the two runtime evaluators, service-automation/src/builtin/template.ts:122 and ui/packages/core/src/actions/actionCopyTemplate.ts:93, call templateEngine.evaluate, which compiles first (:474-475) — no dialect reaches FORMATTERS.currency with an @ argument. The three in-app describes (flow-text-slot-template.ts:21-23, action-copy-template.ts:162-186, bulk-action.zod.ts:470) spell formatter[:arg] and currency:EUR and say nothing of a path, so they stay true unchanged; titleFormat and promptTemplate ride the same engine. RIGHT.

  4. Never a silent default currency for the new form. In the renderer (plugin-email/src/template-engine.ts:136-141) the code is read and judged by currencyCodeValueRefusal BEFORE the amount is formatted; a refusal throws TEMPLATE_INVALID and formatValue is never reached, so a codePath hole can never hand formatValue an undefined argument (:140 assigns the judged code), which is the only way the 'USD' default at template-engine.ts:84 is reached. currencyCodeValueRefusal admits only a string matching /^[A-Z]{3}$/ as stored (no trim), so missing, null, eur, ' EUR', a number and an object are all refused, each named with the hole, the path and the value (describeCodeValue); the email-service.ts:1479-1486 wrap prefixes email template NAME (locale=L) and rethrows every non-TEMPLATE_INVALID error unchanged. A null amount with a missing code is still refused (the code is judged first). formatValue('currency', …) itself still throws RangeError for a non-code argument (:79-81) and never reads a path. No USD appears anywhere on the new path. The feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render #26799 pin files (template-hole-grammar.test.ts, template-hole-grammar.render.test.ts, validate-email-template-hole-grammar.test.ts, the door test's existing cases) are absent from the file list, so they are unchanged; their green is Test Core's verdict in ③. RIGHT.

  5. Save time: an undeclared @path is refused when the template declares variables; accepted when it declares none. judgeMailHoleGrammar(face, { variables }) (mail-hole-grammar.ts:141-165) refuses currency-code-path-undeclared only when variables.length > 0 and no declared name equals the path or is a root it lies under (isDeclaredPath :116-118). The lint rule passes declaredVariableNames(template.variables) (validate-email-template-hole-grammar.ts:65-70, a malformed entry declares nothing); the publish door reaches it through runtime-gate.ts:190 (email_template → emailTemplates), 422 INVALID_METADATA pinned on code, status and issues in the metadata-protocol test. With no variables the path is judged at render (item 4). Honest in the describe: variables[].name says "When the template declares any variable, the path of a currency:@path argument must be one of them or lie under one" (email-template.zod.ts:114-117); the lint changeset, the docs page (email-templates.mdx:115-117) and MailHoleGrammarOptions say the same with the no-variables case spelled out. It is also consistent with how a hole's own {{ path }} is treated today: no door judges a hole path against variables either; only required ones are enforced, at render (requireVars). RIGHT.

  6. Tests, read against the floor: formula (38) — 3 accepted and 10 refused path shapes, mail parse carrying codePath, malformed marks refused as currency-code-literal quoting the rule, declared/undeclared/root/none-declared/literal-not-judged, value refusals for six shapes, in-app compile + validateExpression + evaluate refusing by name, formatValue throwing on the unresolved mark, and the spec-describe parity pin; plugin-email (12) — one bundle with en-US and zh-CN rows, EUR and CNY records formatted in every face (€1,234.50 / CN¥1,234.50 / ¥1,234.50), sendTemplate delivering two records in their own currencies, three refusals in both locales asserting the TEMPLATE_INVALID: email template contract.overdue (locale=L) prefix, the hole, the value, no $ / US$ / USD 1 in the message and nothing sent, the html face judged on its own path, the literal form and the bare-path refusal unchanged; lint (13) — accept with no variables and with the path or its root declared, refuse per face, malformed marks, hint quoting the rule, every os command, the runtime publish gate; metadata-protocol (+1) — the door's code + status + issues, plus the declared and the no-variables admits. Two currencies, two locales, refusals with code asserted and no USD: present. RIGHT.

  7. Public surface (additive): @objectstack/formula exports CURRENCY_ARGUMENT_RULE, currencyCodePath, currencyCodeValueRefusal, types TemplateHoleDialect, MailHoleGrammarOptions; judgeHoleFormatter gains an optional fifth parameter; TemplateHoleGrammarRule gains two members; ParsedMailHole gains codePath; judgeMailHoleGrammar gains an optional options argument. No removal, no rename. RIGHT.

Should-fix (level unaffected, no sentence this PR publishes is false):

  • S1 — packages/lint/src/authoring-rules.ts:2458-2463, the comment above the rule's registry row, still reads "a currency argument that is not a literal ISO 4217 code is refused, naming the literal-only rule". The diff leaves it; it is now incomplete (the marked path is admitted; a fourth rule exists). A source comment, not a runtime string — fix in the next touch of that file or a follow-up.
  • S2 — the new MAIL_TEMPLATE_HOLES describe says "with one formatter from the template engine whitelist per hole". The one-formatter half is refused (one-formatter); the whitelist half is not: judgeHoleFormatter never checks TEMPLATE_FORMATTERS, and the renderer prints the raw value for an unknown formatter (plugin-email/src/template-engine.ts:150, "unknown formatter → raw"). Pre-existing and declared unchanged in feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render #26799's review (① item 7); this PR is the first to state the whitelist in a published .describe(). As a grammar statement it is true; as a promise of refusal it is not made. Either refuse an unknown mail formatter at save (a narrowing, its own card) or soften the clause to "an optional formatter". Carrier: the seat; not this PR's defect.

② Semver level

Four changesets, read against what the diff publishes:

  • @objectstack/formula minor — Clause-②: yes, Metadata-protocol: added. RIGHT: a form every door refused until now is accepted in the mail dialect, in a hole grammar the contract review on feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render #26799 read as spec-documented and the retriage (6452088840) ruled a protocol addition; new exports ride the same level. The in-app dialect's currency-code-path-mail-only refuses a form it already refused (as currency-code-literal), so nothing narrows.
  • @objectstack/plugin-email minor — yes / added. RIGHT (a stored value the renderer now renders; the new refusals are all on the new form).
  • @objectstack/lint minor — yes / added. RIGHT; currency-code-path-undeclared refuses only a form that was never accepted, so added stands, not narrowed.
  • @objectstack/spec minor — yes / added, descriptions only. RIGHT by the retriage's own reading: the email_template face describes are where the protocol states its hole grammar, and the regenerated reference (content/docs/references/system/email-template.mdx) carries the sentence to readers. No Zod shape, default or key moves (z.string() unchanged), so a reader could argue none / patch; added follows the ruling that item (b) is a domain:spec protocol addition and is the conservative level. Not a finding.
  • @objectstack/metadata-protocol: a test only, no changeset. RIGHT. Check Changeset success on the head.
  • ADR-0087: not-required with its reason — RIGHT: a Clause-②: yes changeset with no (narrowing) arm owes no disposition marker (check-adr-0087-registration reads the arm), and the reason holds: the marked form was refused at save and at render before, so no stored template or built artifact carries it and every form accepted before renders as before. ADR-0032 §3 read (docs/adr/0032-unified-expression-layer.md:76-80): holes are paths plus a whitelisted formatter, never logic; the ADR fixes no shape for a formatter's argument, and @path is a path, not logic, so no ADR amendment is owed and no scripts/adr-anchors/ entry names these files.
  • PR body carries Clause-②: yes, Metadata-protocol: added, both matching the claim 6452747733 and all four changesets.

③ Boundary flags

Every dev flag, deviation and open_questions entry, answered or escalated:

  • skills/objectstack-formula/SKILL.md:448-450 (Tier H), sentence by sentence. "A currency argument is a literal code (currency:EUR)" — true: the in-app dialect admits only /^[A-Z]{3}$/ (template-engine.ts:312-326), and the literal form is the mail dialect's default too. "only an email template may read it from the send's data instead, {{ amount | currency:@currency_code }}" — true: 'mail' is passed only at mail-hole-grammar.ts:95,100, the in-app judge refuses the form as currency-code-path-mail-only, and the example is the accepted shape. The sentence sits under the template dialect row whose carriers name system/email-template subject / bodyHtml / bodyText (:444), so "email template" names a carrier the reader has just seen. The paragraph's existing sentences ("Missing template paths render as the empty string", "a template holds a path and a formatter, nothing else") stay true. Token ceiling: the file at the head is 22692 bytes; minus the 20-byte version: line, ceil(22672 / 4) = 5668 against the CEILINGS row 5997 (scripts/check-skills-token-ratchet.mjs:444); main reads 5627 (22527 bytes); the gate and check:skill-docs / check:skill-refs run in Type Check · source gates, success on the head. 471 → 473 lines; no other file mirrors the sentence; no other skill teaches the currency hole formatter (every other currency in skills/ is the field type or a dashboard measure). Docs: content/docs/automation/email-templates.mdx:91-121 read sentence by sentence against items 1-5 and the plugin-email test's outputs — each true; "currency with no argument formats in US dollars" is template-engine.ts:84. The in-app pages that teach a no-argument | currency (flows.mdx:472, :2332; formulas.mdx:79) stay true. The four CLI transcripts feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render #26799 touched carry rule counts only and name no currency rule.
  • Dev question 1 — currency with no argument still formats in USD. No sentence this PR publishes is made false by leaving it: the docs page, the formula and plugin-email changesets and the FORMATTERS comment all say no argument formats in US dollars, and item 4 shows the new form can never reach that default. Triage's "⛔ never a silent default currency" (6441491903) was written about a PATH argument substituting USD; the retriage graded item (b) and did not re-grade the no-argument default; the release 6451239827 carried it so it is not lost. Judged: correct to leave out of this PR — a (narrowing) arm would change this PR's face and its migration note. The dev's recommendation A (a separate narrowing card, prescription currency:USD or in mail currency:@path, one-line D3 note, migrating the 2 docs, 4 describes and 4 pins the census names) is sound; the seat files it. Escalation: the seat's card, not this record's FAIL.
  • Dev question 2 — the eval owed in skills/objectstack-formula/evals/cel-predicates-formulas.json. The PR's Skills-docs: follow-up owed — skills/objectstack-formula/evals/cel-predicates-formulas.json is the claim grammar's own form (pm-dispatch SKILL.md:313: updated here | follow-up owed — FILES | follow-up #N | none — REASON; updated here requires the evals to exercise it). Its absence is a missing sentence, not a false one: nothing in the PR claims an eval exercises currency:@path. The checklist (review-checklist.md:18) requires the seat to file the card and rewrite owed — FILES to #N before ACCEPT; the eval text the dev proposes (ask for an email_template showing an overdue amount in each contract's own currency; expect {{ amount | currency:@currency_code }}, a code in capitals, the path declared when the template declares variables) matches items 1, 4 and 5. Escalation: the seat files it; a Tier H surface, so its own PR needs its own approval.
  • Deviation: packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts beyond the claim's named files — test only, no changeset, the publish-door host pin the order's "lint / publish-door tests" asks for, beside feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render #26799's cases in the same file. Answered.
  • Deviation: branch claude/project-thread-wonj87, not claude/issue-26660-* — the claim 6452747733 states it as measured; Implemented-by below carries it. Answered.
  • Deviation: one merge of origin/main (511e09b1) — the net diff against main at the head is the same 18 files as the API diff; no main content rides in under the PR's name. Answered.
  • Deviations: reviewer request not made; PR activity not subscribed — procedural, outside this record. Noted.
  • Attribution: both commits end with the model-free trailer pair (Claude-Session, Co-authored-by: Claude); the PR body carries the AGENTS.md session-URL footer and, above it, the harness's emoji footer — two footers, no model identifier. Wording only.
  • In-app reading of a code from data is not built — noted in the acceptance notes, refused by name (item 3), no defect. Answered.
  • Governance: skills/** in the file list makes the whole PR Tier H; draft; head repo is the base repo; 779 + 50 changed lines; Governed Surface Queue Guard success. No AI seat merges, queues, arms auto-merge or readies this PR; the maintainer's approval (GOVERNED_APPROVERS) lifts it and the owning seat lands it. This record is a review of record, ⛔ not that approval.
  • Check-runs on 511e09b12 (/commits/{sha}/check-runs): read at 2026-10-27T22:24:43Z and 2026-10-27T22:28:22Z, the gate-carrying Test Core (1/8 … 8/8), Type Check · workspace and Type Check UI were in_progress — an honest reading, not a pass; polled once a minute after that. Final reading at 2026-10-27T22:39:49Z, 47 rows, none pending: every one of the eight required contexts concluded success — Lint & Repo Gates, TypeScript Type Check, Test Core (and its eight shards), Build Core, Governed Surface Queue Guard, Type Check UI, Build & E2E UI; Lint UI skipped by its path selector. Advisory contexts: Check Changeset, Lint · ESLint, Lint · repo & tooling gates, Lint · docs & code guards, Lint · CI, release & engine gates, Lint · PM dispatch-gates, Lint · scoped · query-options erasure, Lint · scoped · tooling self-tests, Lint · scoped · slot lookup & corpus, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Spec property liveness, Dogfood Regression Gate, Plan Test Core shards, Build Docs, Build UI Deps, Test UI, Check Documentation Links, Check PR Size, PR-body guards, Auto Label, Flag docs affected by code changes, filter — all success; Lint UI, the dogfood and Test UI shards, Temporal Conformance, the upgrade harness and the tarball smoke — skipped by selector or opt-in.

Implemented-by: claude/project-thread-wonj87
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: PASS

…unknown-formatter refusal

The email_template face descriptions said a hole takes "one formatter from
the template engine whitelist", but no door refuses an unknown mail
formatter today: the renderer prints such a hole raw. They now say "at most
one formatter per hole (a second, chained formatter is refused)". The
authoring-rules comment for email-template-hole-grammar names the marked
`currency:@path` form and the undeclared-path check instead of a
literal-only rule. Reference docs regenerated.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 49258decf331b8a12c16270a505d99bf68ab5b27
Local-runs: none
Scope: packages/spec/src/system/email-template.zod.ts, skills/objectstack-formula/SKILL.md, packages/lint/src/authoring-rules.ts, content/docs/references/system/email-template.mdx + packages/formula/src/template-engine.ts, packages/formula/src/mail-hole-grammar.ts, packages/lint/src/validate-email-template-hole-grammar.ts, packages/plugins/plugin-email/src/template-engine.ts, packages/formula/src/template-currency-code-path.test.ts, .changeset/26660-currency-code-path-spec.md

Round 2, adopting round 1. The head 49258decf is exactly one commit on 511e09b12 (parent read from the commit: 511e09b12f88b60f995cb0483a1b27c0d4b68339), answering the round-1 record 6455368454 (S1, S2). The diff between the two heads is three files, +14 / −11: packages/spec/src/system/email-template.zod.ts (the MAIL_TEMPLATE_HOLES constant and its TSDoc), packages/lint/src/authoring-rules.ts (the comment above the validateEmailTemplateHoleGrammar registry row) and content/docs/references/system/email-template.mdx (regenerated). No executable line, test, changeset, docs page or skill line moves, so every judgment in round 1 (① items 1–7, ② every level and the ADR-0087 / ADR-0032 reading, ③ the skills hunk sentence by sentence, the token count 5668 of 5997, both dev questions, every deviation) stands unchanged and is adopted here, not restated. Inputs this round: the PR's current body (replaced by the seat, read back), the diff 511e09b12..49258decf from a fetched ref, the cited code at the new head, cards #26939, #26940 and #26948, and the check-runs on 49258decf (readings declared in ③).

① Derived judgments

Round 1 adopted. What this commit changes, each named RIGHT or WRONG against the tree at 49258decf:

  1. S2 closed — the email_template face describes. MAIL_TEMPLATE_HOLES (email-template.zod.ts:108-111) now reads "with at most one formatter per hole (a second, chained formatter is refused)" and no longer names a formatter whitelist. The refusal it now promises is real at every reader of the mail grammar: judgeHoleFormatter returns one-formatter when the formatter part carries a second | outside a closed quoted argument (template-engine.ts:302-311, chainsASecondFormatter :271-282); parseMailHole reaches it on both parse branches (mail-hole-grammar.ts:95,100), so the lint rule email-template-hole-grammar refuses it at os validate / build / lint (commands: ALL) and at the email_template publish door (surfaces: CLI_AND_RUNTIME, runtimeTypes: ['email_template'], authoring-rules.ts:2475-2481), and the renderer throws TEMPLATE_INVALID for one that still reaches it (plugin-email/src/template-engine.ts:128) — the feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render #26799 pins, unchanged in this PR, hold all three. The old sentence's half that no door enforces (an unknown formatter name renders raw, plugin-email/src/template-engine.ts:150) is now neither stated nor promised; it is filed 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 (open, domain:spec, cites record 6455368454 S2) and cited from the constant's TSDoc (:105-106), a comment — ⛔ not from the describe string itself, so no tracker number reaches an author. The currency sentence is byte-unchanged, so the formula parity pin (template-currency-code-path.test.ts:487-490, toContain(CURRENCY_ARGUMENT_RULE)) still holds. The regenerated reference (email-template.mdx:52-54) carries the new sentence on all three faces with the | escaped as the generator writes it; no copy of the old wording ("from the template engine whitelist per hole") remains anywhere in the tree. The spec changeset's own sentence ("with one formatter per hole") was already true and is unchanged. RIGHT.

  2. S1 closed — the registry comment. authoring-rules.ts:2459-2465 now says a currency argument "that is neither a literal ISO 4217 code nor a marked @path into the send's data is refused, naming CURRENCY_ARGUMENT_RULE", and "a marked path the template's declared variables do not cover is refused (currency-code-path-undeclared)". Both true to the validator: the mail arm of currencyArgumentRefusal quotes CURRENCY_ARGUMENT_RULE (template-engine.ts:200-207), and judgeMailHoleGrammar emits currency-code-path-undeclared when a declared name neither equals the path nor is a root it lies under (mail-hole-grammar.ts:149-162). One reading note, not a finding: the comment does not spell out that the declared-path check runs only when the template declares at least one variable; the validator's own header (validate-email-template-hole-grammar.ts:20-24) and the describe on variables[].name do, and "do not cover" over an empty declaration set is how the code reads it. RIGHT.

  3. Nothing else changed. git diff --stat 511e09b12..49258decf: the three files above only. The round-1 floor files (formula/src/template-engine.ts, mail-hole-grammar.ts, index.ts, plugin-email/src/template-engine.ts, email-service.ts, lint/src/validate-email-template-hole-grammar.ts, all four test files, all four changesets, content/docs/automation/email-templates.mdx, skills/objectstack-formula/SKILL.md) are byte-identical between the heads. RIGHT.

② Semver level

Round 1 adopted: four minor changesets (Clause-②: yes, Metadata-protocol: added), the metadata-protocol test without one, ADR-0087: not-required. A describe-text change inside a changeset already declared minor / added moves no level; no changeset file changed. Check Changeset on the new head: see ③.

③ Boundary flags

  • PR body (replaced by the seat, read back byte for byte). Skills-docs: follow-up #26940 — skills(objectstack-formula): no eval exercises currency:@path for a mail amount in the record's own currency (follow-up owed by #26660) #26940 exists, open, domain:skills, Blocked-by: #26660, and asks exactly the eval round 1 named (one case in skills/objectstack-formula/evals/cel-predicates-formulas.json, must_contain currency:@, refusing the bare path, a hard-coded code and the no-argument form); this is the claim grammar's follow-up #N form, so the round-1 "the seat files it and rewrites to #N" is discharged. #26939 exists, open, domain:spec, carries the dev's no-argument-USD census verbatim and asks for the narrowing round 1 judged right to keep out of this PR. #26948 exists, open, domain:spec, carries S2's measurement (render() prints an unknown formatter raw; the mail grammar never checks TEMPLATE_FORMATTERS; the in-app dialect refuses at compile) and the acceptance note names it. The @objectstack/spec bullet now says "at most one formatter per hole, a second chained formatter refused" and "promise no refusal of an unknown formatter name, because none exists today" — both match item 1. The round-2 evidence block describes the one commit as this review read it (describes, the registry comment, regenerated docs); its build, suite and gate counts are the dev's local readings and are not re-run here — the check-runs below are the verdicts of record. Every other body sentence is round 1's and unchanged.
  • Governance: unchanged — skills/** in the file list keeps the whole PR Tier H; draft; head repo is the base repo; 785 + 53 changed lines; Governed Surface Queue Guard success on the new head. No AI seat merges, queues, arms auto-merge or readies this PR; the maintainer's approval lifts it and the owning seat lands it. This record is a review of record, ⛔ not that approval.
  • Check-runs on 49258decf (/commits/{sha}/check-runs): at 2026-10-27T23:36:14Z 43 rows, Build Core, Test Core (1/8 … 8/8), the Lint · lanes and the Type Check · lanes in_progress — an honest reading, not a pass; polled once a minute after that (the poll was restarted once at 2026-10-27T23:58:00Z, 50 rows, two Test Core shards still running, nothing red). Final reading at 2026-10-28T00:01:20Z: 51 rows, none pending, no row failure, cancelled or timed_out; every one of the eight required contexts concluded success — Lint & Repo Gates, TypeScript Type Check, Test Core (and its eight shards), Build Core, Governed Surface Queue Guard, Type Check UI, Build & E2E UI; Lint UI skipped by its path selector. Advisory contexts (Check Changeset twice, every Lint · and Type Check · lane, Spec property liveness, Dogfood Regression Gate, Plan Test Core shards, Build Docs, Build UI Deps, Test UI, Check Documentation Links, Check PR Size, PR-body guards twice, Auto Label, Flag docs affected by code changes, filter) all success; the dogfood and Test UI shards, Temporal Conformance, the upgrade harness and the tarball smoke skipped by selector or opt-in. Type Check · source gates (which carries check:docs, check:skill-docs and the skills token ratchet) success, so the regenerated reference matches the describe.

Implemented-by: claude/project-thread-wonj87
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 (席位终稿)

  • 改了什么:邮件模板新增 {{ 金额 | currency:@currency_code }} 写法,币种代码从发送数据里读,一个模板就能按每条记录自己的币种显示金额。站内模板(流程通知、按钮文案等)对这个写法按名拒收。
  • 为什么改:按合同发提醒的应用,各合同金额的币种不同;此前只能写死一个币种,或者只显示裸数字。
  • 风险与回滚:只放宽了一个以前被拒的写法,旧写法和旧的拒收全部不变。数据里没有合法代码(缺失、null、小写)时,渲染报 TEMPLATE_INVALID,绝不用美元顶替。回滚 = revert 本 PR。
  • 先看:packages/formula/src/template-engine.ts(@ 标记与方言判定)、packages/plugins/plugin-email/src/template-engine.ts(取值与拒收)、packages/formula/src/mail-hole-grammar.ts(声明变量检查)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-email exec vitest run src/template-currency-code-path.render.test.ts,预期 12 项全绿:EUR 和 CNY 两条记录在 en-US、zh-CN 下各按本币显示,三种坏代码均报 TEMPLATE_INVALID。
  • 需要你做的:本 PR 改了 skills/objectstack-formula/SKILL.md(受治理面 Tier H),请 @os-zhuang 或 @hotlong 审批;批准后由席位负责 ready、入队和落地。

席位意见:接受,等维护者审批。契约复核第 1 轮 PASS(6455368454),附带的两处文字问题(S1、S2)已在第 2 轮修正,第 2 轮 PASS(6456767827)。三处缺口已立卡:无参 currency 仍按美元(#26939)、技能评测(#26940)、邮件模板里写错的格式化器名没人拒收(#26948)。

席位核对 · ACCEPT (Tier H, awaiting a human merge) · domain:spec seat 3 · os-support-ai · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-28T00:03Z

Head: 49258decf331b8a12c16270a505d99bf68ab5b27 · card #26660 (item b) · item (c) landed in #26799

  1. 形态: a draft PR to main. Its only closing keyword is Fixes #26660. It has 19 paths, +785 / −53 (838 lines, under 3000). check-governed-merges --pr 26937 says governed, because of one skills/** path, skills/objectstack-formula/SKILL.md. So this is the Tier H final package: this record, needs-user-decision on the PR, and review requests to GOVERNED_APPROVERS (os-zhuang and hotlong are already requested). The PR stays draft until an authorized approval. After that, the seat runs the pre-landing checks, clears the label, readies the PR and arms auto-merge.

  2. 前提实测: the premise holds. Before this PR, currency:@path was refused at save and at render, so a mail template could only hard-code a code. Round 1 found the two accept sets disjoint: the literal /^[A-Z]{3}$/ against the marked path /^@(\w+(?:\.\w+)*)$/. Every shape refused before stays refused, including the feat(formula,plugin-email,lint)!: a mail template hole takes one formatter and a literal currency code, refused at authoring and at render #26799 pins currency:currency_code and currency:record.currency_code. The in-app dialect refuses the marked form by name (currency-code-path-mail-only). No stored template or built artifact carries currency:@path, so ADR-0087: not-required is right.

  3. 门禁 check-runs (on this head): 51 runs: 42 success, 9 skipped (opt-in or path selector), none red or pending, read at 2026-10-28T00:01Z. Mergeable state is clean. The contract-tier review ran twice:

    • round 1 PASS on 511e09b1 (6455368454), with two text items, S1 and S2;
    • round 2 PASS on this head (6456767827), which adopts round 1 and confirms both fixes are true to the validator and renderer.

    The dev's runs on this head: pnpm build 116 of 116; spec 22101, formula 1522, plugin-email 724, lint 7483, metadata-protocol 21694 and service-messaging 734 tests passed; dispatch gates 114 derived, 114 run, 114 exit 0, 0 NOT-MEASURED; check-skills-token-ratchet exit 0.

  4. 接受的偏差: (a) Clause-②: yes, Metadata-protocol: added. This widens a formatter argument that was refused before; formula, lint, plugin-email and spec each get a minor version. (b) S2: the spec descriptions promise only "at most one formatter per hole (a second, chained formatter is refused)". Every door refuses a chained formatter today (the lint rule on every command, and the renderer's TEMPLATE_INVALID). No door refuses an unknown formatter name, so the text no longer claims that it does.

  5. 范围外去向:

    All three cards are open and in the spec queue.

  6. 落地条款: the prose faces were checked against the diff on this head:

    • the PR body, which the seat applied at 2026-10-27T23:35Z, read back byte-identical;
    • the four changesets;
    • the skill line;
    • content/docs/automation/email-templates.mdx and the regenerated email-template.mdx reference.

    No copy of the old "whitelist per hole" wording remains. This PR needs a maintainer's approval, so the seat does not ready it or arm auto-merge until an authorized APPROVED review lands. Then the seat follows it to MERGED.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Director note · needs-user-decision leaves this PR · 2026-10-28T00:49Z

Recorded by the director seat (session_01KrKXQVvgYk2AJ4VkqMobyb, ledger objectstack#12708). Since objectstack#26671 the decision box holds decision cards only; a governed PR's Tier H terminal needs no director relay. This PR's pieces are complete: seat ACCEPT 6456811179, 维护者速读, review requested from os-zhuang and hotlong, head 49258dec green, contract review rounds 1–2 PASS. The one act is the maintainer's APPROVE on GitHub; the owning seat then readies, enqueues and lands. ⛔ Not a claim.

@os-zhuang
os-zhuang marked this pull request as ready for review October 28, 2026 09:37
@os-zhuang
os-zhuang enabled auto-merge October 28, 2026 09:37
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 2205863 Oct 28, 2026
60 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-wonj87 branch October 28, 2026 09:58
os-support-ai pushed a commit that referenced this pull request Oct 28, 2026
os-support-ai pushed a commit that referenced this pull request Oct 28, 2026
…@path form (#26937) beside the tenant default

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
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

Development

Successfully merging this pull request may close these issues.

Mail templates cannot format an amount in the record's currency: currency:ARG takes only a literal ISO code, and a path argument silently renders USD

3 participants