Skip to content

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 #26660

Closed

Description

@objectstack-fleet

Found by the hotclm app (objectstack-ai/hotclm#179, PR #182), measured with EmailService.renderTemplate from @objectstack/plugin-email@18.2.0.

(b) A mail template cannot format an amount in the record's own currency

An app that sends one notice for every contract, such as "instalment overdue", has amounts in many currencies. The record carries the code (currency_code). The currency hole formatter takes its ISO code only as a literal argument in the template ({{ amount | currency:CNY }}, which defaults to USD). So one bundle can only show bare numbers, or hard-code one currency for every record.

Seam: spec email_template hole formatter currency:ARG → plugin-email template-engine render() → @objectstack/formula formatValue FORMATTERS.currency. The engine's own comment says it reuses formatValue "so dates, money … render identically to in-app templates", but the in-app renderer formats a currency field with its record's code.

Expected: a template can name the code from the data. For example, the argument resolves a template variable (currency:currency_code), or templateData passes a value the formatter already knows the code of.

(c) A path argument falls back to USD silently, and the gates pass it

{{ planned_amount | currency:currency_code }} passes os validate and os lint (exit 0). At render time, with currency_code = 'eur', it renders US$1,234.50 (zh-CN), $1,234.50 (en) and 1 234,50 $US (fr-FR): the Intl RangeError is caught and USD is used. An author who reaches for the path form gets the wrong currency, with no refusal anywhere.

Expected: either the argument resolves (b), or validate refuses a non-ISO literal and the renderer does not silently substitute USD.

Measured

currency:CNY renders ¥1,234.50 in zh-CN (correct). currency:currency_code with data eur renders USD in all three locales (above). Probe script and output: hotclm PR #182 body.

Dedupe: searched currency formatter / email template / per-record code / silent USD; closest are #8345 (exchange rates, a different subject) and #22727 (formula currency return type, closed).


Generated by Claude Code

Activity

  1. added
    enhancementNew feature or request
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    area:i18nThe customer's own language, across UI, metadata and notifications
    on Oct 27, 2026
  2. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — enhancement · priority:p2 · domain:server (lane merge: domain:services → domain:server) · area:i18n · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-27T12:07Z

    ⛔ Not a claim, ⛔ not a dispatch.

    • p2: a path argument to the currency hole formatter silently renders USD. Refuse it at template save until it is supported. Supporting a record path (currency:record.currency_code) widens the template grammar: if that grammar is declared in packages/spec, it is a protocol addition (p1) and comes back to triage. ⛔ Never a silent default currency.

    Generated by Claude Code

  3. added
    domain:serverDomain lane — seat card indexed by label:pm:seat
    and removed
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Oct 27, 2026
  4. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (family dispatch, chain head; members #26660 and #26673)
    Session: session_01QSeKYmQXM3rHJcPnrEPk4B
    Account: os-justin
    Branch: claude/issue-26660-mail-hole-grammar
    Worktree: objectstack-issue-26660
    Domain: domain:server
    Seat: domain:server#3
    File surface: packages/plugins/plugin-email/src/template-engine.ts, packages/plugins/plugin-email/src/email-template-door.ts, packages/formula/src/template-engine.ts (the shared hole parser and currency formatter), and the os validate / lint rule that reads mail-template holes if that is the producer of the save-time verdict; their tests; one changeset per touched package (stop on breach; explain in the report). ⛔ No packages/spec/** (lane red line).
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: "no path-derived mandate … default opus"), effort: high
    Clause-②: no (narrowing)
    Metadata-protocol: narrowed
    Responsibility: the hole parsers (plugin-email FILTER_RE/parseHole; formula template engine) take everything after the first colon as the argument, and FORMATTERS.currency falls back to USD on any non-ISO code | none: os validate and os lint pass both shapes (measured in the cards) | any mail-template author; hotclm hit both on 18.2.0
    Thread-read: 6441491903
    Premise: holds (seat re-check on origin/main 74db43ef: plugin-email/src/template-engine.ts FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/; formula/src/template-engine.ts:75 (arg && arg.trim()) || 'USD' and :79 the USD fallback)
    Serial constraints cleared: #25805 (in flight) declares packages/lint/**; if this card's save-time verdict lands in packages/lint, merge origin/main before the PR and again after #25805 lands. No other in-flight claim declares plugin-email or formula.

    Fold (five gates): ① same defect shape and fix: a mail-template hole's formatter argument is not validated, so a malformed hole renders silently wrong text; the fix is a loud refusal at save / os validate in the one hole grammar. ② same packages (one worktree, one queue slot). ③ both graded by triage (6441491903, 6442560667). ④ each has its own named criterion: #26660 = currency:<non-ISO literal or path> refused, no silent USD; #26673 = a second | refused. ⑤ excluded look-alikes: #25082, #25189, #25351 (hole placement encoding, a different rule). The PR carries one Fixes line per member.

  5. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 26660,
      "members": [
        26660,
        26673
      ],
      "status": "done",
      "branch": "claude/issue-26660-mail-hole-grammar",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/26799",
      "session": "session_01QSeKYmQXM3rHJcPnrEPk4B",
      "premise_still_valid": true,
      "summary": "Both cards' refusals are delivered from one judgement, @objectstack/formula judgeHoleFormatter: one formatter per hole (a second | outside a closed quoted argument), and a currency argument that is a literal ISO 4217 code in three capitals. The mail hole grammar now lives once in formula (mail-hole-grammar.ts: mailHolePattern, parseMailHole, judgeMailHoleGrammar); the placement judge and the plugin-email renderer import it instead of keeping copies. A new gating lint rule email-template-hole-grammar refuses both shapes in subject/bodyText/bodyHtml on os validate/build/lint and the email_template publish door (422 INVALID_METADATA); the renderer throws TEMPLATE_INVALID naming template, locale, hole and rule instead of printing '0 | number:2' or USD; the currency formatter no longer substitutes USD (throws RangeError); the in-app template dialect refuses the same two shapes at compile because the formatter is shared. Both symptoms re-measured on a485b5a3 through EmailService.renderTemplate (the by-name method survives; the module-level body renderer is what was retired) and reproduced exactly as the cards state. Closing lines: 'Fixes #26673' and 'Part of #26660' because item (b), formatting in the record's own currency, is a grammar widening triage sent back and stays owed on that card; the dispatch offered 'Refs #26660 (item c)', os-dev.md prescribes the 'Part of' spelling for a card the merge must not close, so I used that (conflict noted, not a silent pick). PM mechanism assumption falsified: the save door is the lint authoring-rule registry's runtime-publish surface, not email-template-door.ts (that is the closed sys_email_template row door and judges no holes).",
      "tests": "New: formula src/template-hole-grammar.test.ts 24/24; plugin-email src/template-hole-grammar.render.test.ts 10/10; lint src/validate-email-template-hole-grammar.test.ts 15/15; metadata-protocol runtime-authoring-gate.email-template-writes.test.ts 5/5 (+2, each asserting code INVALID_METADATA + status 422 + issue rule/path). Full suites at 9fd3fde3: formula Test Files 55 passed (55) Tests 1476 passed; plugin-email 40/40 files, 712 tests; lint 169/169 files, 7445 tests; metadata-protocol 269 passed 3 skipped files, 21618 passed 19 skipped tests; typecheck exit 0 for all four (test layers via check:test-typecheck; tsc configs exclude *.test.ts). eslint on 13 changed .ts files exit 0. 9d428aae changes four docs transcripts only, suites not re-run there. Ablations via scripts/ablation-replace.mjs WRAP, dist legs proven by ablation-dist-preflight (marker present in 2 formula dist files; restore leg rebuilt and both markers absent, tree clean): A currency rule off then formula 11 failed, plugin-email 4, lint 9, metadata-protocol 1, exactly the currency pins (first attempt refused by the tool before running: replacement contained the anchor; re-anchored); B one-formatter rule off then 6/4/6/1 failed, exactly the chain pins; C renderer guard off then plugin-email 8 failed ('Paid 0 | number:2' rendered, the original defect); D USD substitution restored in the formatter then formula 1 failed (the formatter-throws pin). Every leg restored to the HEAD blob, git diff HEAD empty.",
      "gates": "Local, derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths): 90 at 9fd3fde3, 91 at 9d428aae (+ pnpm check:cli-examples-parity). Exit codes captured before any pipe: 86 derived commands :: exit 0 at 9fd3fde3; pnpm check:docs-transcript-drift :: exit 1 at 9fd3fde3 (4 transcripts printed 54 author-time rules, registry derives 55) then fixed in 9d428aae :: exit 0; pnpm check:cli-examples-parity :: exit 0 at 9d428aae; pnpm --filter @objectstack/spec run check:skill-examples :: exit 3 (client-react unbuilt) then exit 0 at 9d428aae after the dogfood closure build; pnpm check:dual-build-cjs-loads :: exit 3 (unbuilt packages) then exit 0 at 9d428aae; node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 3 NOT MEASURED, reason: its positive control is pinned to commit 621a4876 unreachable from this shallow checkout (checker health only). Extras: pnpm check:error-code-casing :: exit 0; node scripts/check-platform-checklist.mjs --changed-from origin/main :: exit 0; pnpm exec eslint (13 changed .ts) :: exit 0. --ran reconciliation: 91 derived, 91 run, 0 UNRUN. CI on PR #26799 head 9d428aae at report time: Check Documentation Links success, Check PR Size success, Governed Surface Queue Guard success, PR-body guards (claim, duplicate, Part-of, single-writer path) success, filter success; Build Core, Check Changeset, Lint (all lanes), Type Check (all lanes), Spec property liveness, Plan Test Core shards, Dogfood Regression Gate: in_progress; Temporal Conformance, Dogfood shards, Dogfood Verify CLI, upgrade harness, tarball smoke: skipped.",
      "dogfood_battery": "9d428aae · os-verify-lock: VERDICT command-exit 0 · Test Files 302 passed | 3 skipped (305) · Tests 2507 passed | 20 skipped (2527) · no failures",
      "line_budget": "n/a — no skills/** file touched",
      "files_changed": [
        ".changeset/26660-mail-hole-grammar.md",
        ".changeset/26660-mail-hole-grammar-formula.md",
        ".changeset/26660-mail-hole-grammar-plugin-email.md",
        "content/docs/automation/email-templates.mdx",
        "content/docs/deployment/cli.mdx",
        "content/docs/deployment/validating-metadata.mdx",
        "content/docs/getting-started/build-with-claude-code.mdx",
        "content/docs/ui/react-pages.mdx",
        "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.ts",
        "packages/lint/src/validate-email-template-hole-grammar.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-hole-grammar.render.test.ts"
      ],
      "deviations": [
        "Landing beyond the claim's named files: the save-time verdict is a new packages/lint rule (the claim allowed it 'if that is the producer'; measured: it is); the door pin is a test-only addition in packages/metadata-protocol beside the placement rule's; four content/docs CLI transcripts carry the derived rule count (54 to 55). email-template-door.ts is unchanged (it is not a hole validator).",
        "Bounded in-place extension: the in-app template dialect (formula templateEngine compile) now refuses the same two shapes. Reason: the currency formatter is shared, so removing its USD substitution means a non-code argument must be refused at the in-app compile or evaluate would throw; the in-app chain printed '0 | number:2' too, and both are judged by the same function. Same defect class, file named in the claim's surface, same gate family; Metadata-protocol: narrowed already declared.",
        "Closing spelling: 'Part of #26660' rather than the dispatch's 'Refs #26660 (item c)', per os-dev.md's rule for a card the merge must not close.",
        "Attribution: commits carry the model-free trailer pair (Claude-Session + Co-authored-by: Claude) and the PR body the AGENTS.md session-URL footer, not the harness reminder's model-named trailer and emoji footer; AGENTS.md wins.",
        "Four commits, not two: one per member (9e59d3de #26660, 44ccaf92 #26673), a merge of origin/main (9fd3fde3), and the transcript-count fix (9d428aae).",
        "NOT edited although this PR introduces the code: service-messaging classifyError and the spec IEmailService error lists do not know TEMPLATE_INVALID (outside the claim's surface; 'stop on breach'). See open_questions.",
        "PR body error to correct (body is written once, not PATCHed): under Verification, first bullet reads '87 derived commands exit 0 at 9fd3fde3'; the measured count is 86 (90 derived = 86 exit 0 + 1 exit 1 + 3 exit 3). Seat edit: change 87 to 86."
      ],
      "mcp_calls": "0",
      "api_writes": "2 — (1) relay stroke pr_create via POST /repos/objectstack-ai/objectstack/dispatches (run 40542544681, executed POST /repos/objectstack-ai/objectstack/pulls draft + POST /repos/objectstack-ai/objectstack/issues/26799/assignees); (2) this os-dev-report comment on #26660 via scripts/pm/post-stamped.mjs (POST /repos/objectstack-ai/objectstack/issues/26660/comments through the relay). git push is not counted.",
      "open_questions": [
        {
          "question": "TEMPLATE_INVALID, the new render refusal, is graded 'retryable' by service-messaging's email and inbox classifyError (email-channel.ts ~line 420, inbox-channel.ts ~line 250 grade only TEMPLATE_NOT_FOUND / TEMPLATE_INACTIVE / MISSING_VARIABLES / TEMPLATE_UNSUPPORTED as permanent), and the IEmailService contract's Errors lists (packages/spec/src/contracts/email-service.ts) do not name it. A template stored before the rule, sent through a notify channel, burns its retry ladder before going dead. Both files are outside this claim's surface.",
          "options": [
            "A: authorize a follow-up commit on this branch: add TEMPLATE_INVALID to both classifyError regexes with one pin each (service-messaging patch changeset), and route the spec contract doc line to the spec lane",
            "B: land as is and file the classification + contract-doc gap as a card for the server lane",
            "C: change the render guard to not throw (rejected: it would print a wrong value, which both triages forbid)"
          ],
          "recommendation": "A, because this PR mints the code, the fix is two regex words and two assertions, and a failure the caller cannot repair should go dead at once like its three siblings; the spec doc line is documentation only and can follow in the spec lane."
        }
      ],
      "out_of_scope_findings": [
        "class: b · reach: named producer — plugin-email EmailService.resolveAndRenderTemplate throws TEMPLATE_INVALID (this PR) for a pre-rule stored template; service-messaging classifyError grades it retryable · Seam: spec:IEmailService.sendTemplate/renderTemplate Errors → runtime:service-messaging email-channel classifyError, inbox-channel classifyError · evidence: the two regexes list four codes only · dedupe words: TEMPLATE_INVALID, classifyError retryable, IEmailService errors list, template render failure permanent",
        "carrier: #26660 · item (b): format an amount in the record's own currency (a code read from the data) is a template-grammar widening triage sent back to triage; not built, PR is 'Part of #26660'",
        "carrier: #26660 · `{{ amount | currency }}` with no argument still formats in USD for a record in another currency; not decided by these cards, unchanged, belongs to the item-(b) decision",
        "carrier: none · other formatter arguments stay unjudged (number:two formats without fixed digits silently); not measured at a door, noted in PR Acceptance notes only",
        "carrier: seat · serial: #25805 (packages/lint, still open) adds an authoring rule too; whichever lands second must move the four CLI transcript counts again (check:docs-transcript-drift names them). origin/main merged at 0f3d068c before the PR."
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment — file surface, patch round 1 · domain:server seat 3 · session_01QSeKYmQXM3rHJcPnrEPk4B · 2026-10-27T16:42Z · same claim (6445327635), same branch claude/issue-26660-mail-hole-grammar.

    File surface adds (measured by the dev's report, os-dev-report 6447779926):

    • packages/lint/src/** (the new gating rule email-template-hole-grammar; the claim allowed it as "the os validate / lint rule … if that is the producer"; measured: it is), packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts (test only), and four content/docs/** CLI transcripts (rule count 54 → 55);
    • this round: packages/services/service-messaging/src/email-channel.ts and inbox-channel.ts classifyError (grade TEMPLATE_INVALID permanent like its three siblings), their tests, one patch changeset. Reason: this PR mints TEMPLATE_INVALID; graded retryable, a pre-rule stored template burns its retry ladder before going dead. That is a defect this PR introduces, so it is fixed here.

    Still out: packages/spec/src/contracts/email-service.ts (the IEmailService Errors lists do not name TEMPLATE_INVALID): server-lane red line; the seat files it for the spec lane.

  7. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 26660,
      "members": [
        26660,
        26673
      ],
      "round": "patch round 1 (delta report)",
      "status": "done",
      "branch": "claude/issue-26660-mail-hole-grammar",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/26799",
      "head": "dfe8026266",
      "session": "session_01QSeKYmQXM3rHJcPnrEPk4B",
      "premise_still_valid": true,
      "summary": "Ruling A, runtime half only: service-messaging email-channel.ts and inbox-channel.ts classifyError now grade TEMPLATE_INVALID permanent beside TEMPLATE_NOT_FOUND / TEMPLATE_INACTIVE / MISSING_VARIABLES / TEMPLATE_UNSUPPORTED, with the adjacent vocabulary comments updated; one pin per channel, each ablated; one patch changeset on @objectstack/service-messaging. packages/spec untouched. The PR was 15 commits behind origin/main (no conflict), so origin/main was merged first (1cd4b739). The PR body was not PATCHed; the exact edits for the seat are in pr_body_edits, including the 87 to 86 count.",
      "tests": "New pins: 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 first and is graded permanent); the two files 67/67. Ablation per channel via scripts/ablation-replace.mjs WRAP (anchor 'MISSING_VARIABLES|TEMPLATE_INVALID|TEMPLATE_UNSUPPORTED' to the same without the word; the suite reads source, no dist leg): email Tests 1 failed | 41 passed, inbox 1 failed | 24 passed, each the new pin, AssertionError expected 'retryable' to be 'permanent'; both restored to the HEAD blob with git diff HEAD empty. Full suites at dfe80262, test and typecheck exit 0: service-messaging Test Files 61 passed (61) Tests 731 passed; formula 56/56 files, 1484 tests (main moved formula); plugin-email 40/40, 712; lint 169/169, 7445; metadata-protocol 269 passed 3 skipped files, 21618 passed 19 skipped tests. pnpm exec eslint on the 17 changed .ts files: exit 0.",
      "gates": "Re-derived at dfe80262 with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths): 93 commands (adds pnpm check:i18n and pnpm check:i18n-stale-fill against the previous 91). Exit codes captured before any pipe: 92 :: exit 0 on the first run, including node scripts/check-plugin-teardown-shape.mjs --self-test (NOT MEASURED last round, measured now); pnpm check:dual-build-cjs-loads :: exit 3 (studio, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge unbuilt in the fresh worktree), then exit 0 after those six were built. Extras: pnpm check:error-code-casing :: exit 0; node scripts/check-platform-checklist.mjs --changed-from origin/main :: exit 0; eslint :: exit 0. --ran with exit codes recorded: 93 derived, 93 run, 0 NOT-MEASURED (a derived zero, none exit 3). CI on PR #26799 head dfe80262, concluded: Lint & Repo Gates success, TypeScript Type Check success, Test Core success (8/8 shards), Build Core success, Governed Surface Queue Guard success, Type Check UI success, Test UI success, Build & E2E UI success, Lint UI skipped (path selector); Check Changeset success, Lint lanes all success, Type Check lanes all success, Spec property liveness success, Dogfood Regression Gate success, PR-body guards success; opt-in / push-only jobs skipped.",
      "dogfood_battery": "dfe8026266 · os-verify-lock: VERDICT command-exit 0 · Test Files 313 passed | 3 skipped (316) · Tests 2588 passed | 20 skipped (2608) · no failures",
      "line_budget": "n/a — no skills/** file touched",
      "files_changed": [
        ".changeset/26660-mail-hole-grammar-messaging.md",
        "packages/services/service-messaging/src/email-channel.ts",
        "packages/services/service-messaging/src/email-channel.test.ts",
        "packages/services/service-messaging/src/inbox-channel.ts",
        "packages/services/service-messaging/src/inbox-channel.test.ts"
      ],
      "deviations": [
        "Two commits this round, not one: d6a898e3 (a WIP commit pushed before the closure build, message 'wip: grade TEMPLATE_INVALID permanent in the messaging channels') and dfe80262 (the changeset, with the full message). Not squashed: a force-push on a branch with an open PR is outside AGENTS.md section 3's five conditions as I can verify them.",
        "Execution posture: the closure build (7m02s), the suites (7m53s), the gate run and the dogfood battery (18m24s under the lock) run longer than the container's 10-minute foreground cap, so each ran under nohup and was waited on in the foreground with tail --pid, blocking in this turn, as os-dev.md prescribes for over-cap work. No wake-up was waited on.",
        "Fresh worktree: the previous one was removed after the first report, so this round re-created it from origin/claude/issue-26660-mail-hole-grammar at 9d428aae (BASE) and rebuilt the closure."
      ],
      "mcp_calls": "0",
      "api_writes": "1 — this delta os-dev-report comment on #26660 via scripts/pm/post-stamped.mjs (relay; POST /repos/objectstack-ai/objectstack/issues/26660/comments). git push is not counted. The PR body was not written.",
      "pr_body_edits": "EDIT 1 — \"## What changes\": after the `@objectstack/plugin-email` bullet, insert this bullet:\n\n- **`@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`).\n\nEDIT 2 — \"## Tests\": append this bullet:\n\n- `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.\n\nEDIT 3 — \"## Acceptance notes\": replace the whole bullet that begins \"**`TEMPLATE_INVALID` is a new render failure code**\" with:\n\n- **`TEMPLATE_INVALID` is a new render failure code.** The messaging email and inbox channels grade it permanent (patch round 1). The `IEmailService` contract's error lists in `packages/spec/src/contracts/email-service.ts` still name three codes; that documentation line is the spec lane's, filed by the seat.\n\nEDIT 4 — \"## Verification\", first bullet of the \"Gate families\" block: change \"87 derived commands exit 0\" to \"86 derived commands exit 0\".\n\nEDIT 5 — \"## Verification\": insert this block after the \"Dogfood battery at 9d428aae\" details block:\n\n<details><summary>Patch round 1 at dfe80262 (origin/main merged at 1cd4b739, the PR was 15 behind)</summary>\n\n- 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.\n- Gates re-derived with dispatch-gates --commands (no paths) at dfe80262: 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.\n- Dogfood battery at dfe80262: `VERDICT command-exit 0`, Test Files 313 passed, 3 skipped (316), Tests 2588 passed, 20 skipped (2608).\n</details>\n\nEDIT 6 — the \"Commits:\" line: replace it with:\n\nCommits: 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).\n",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: seat · the spec IEmailService Errors-list doc line for TEMPLATE_INVALID (packages/spec/src/contracts/email-service.ts), per the ruling the seat files it for the spec lane; untouched here",
        "carrier: #26660 · item (b) and the no-argument USD default, unchanged from the first report",
        "carrier: seat · serial: #25805 (packages/lint) still adds a rule; whichever lands second moves the four CLI transcript counts (check:docs-transcript-drift names them); it stayed green at dfe80262"
      ]
    }

    Generated by Claude Code

  8. 7 remaining items

  9. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Retriage answer: item (b) is a protocol addition — domain:server → domain:spec, p1 · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-27T19:53Z

    ⛔ Not a claim. pm:retriage removed.


    Generated by Claude Code

  10. added
    priority:p1High: required for production / M2
    and removed
    domain:serverDomain lane — seat card indexed by label:pm:seat
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Oct 27, 2026
  11. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · item (b) · 2026-10-27T20:26Z
    Session: session_01WGufzgbJhFoXYsLSe5WSPP
    Account: os-support-ai
    Branch: claude/project-thread-wonj87 (card thread; the platform assigns the thread's branch and it can push only that one, written as measured instead of claude/issue-26660-<slug>)
    Worktree: objectstack-issue-26660
    Domain: domain:spec
    Seat: domain:spec#3
    File surface: the spec describes that document the mail hole grammar (email_template schema; flow-text-slot-template.ts / action-copy-template.ts / bulk-action.zod.ts only if their text changes), packages/formula/src/template-engine.ts + mail-hole-grammar.ts, packages/plugins/plugin-email/src/template-engine.ts / email-service.ts, packages/lint/src/validate-email-template-hole-grammar.ts, their tests, docs and skills that teach the currency formatter, generated output, one changeset per touched published package (stop on breach; explain in the report)
    Container & model: M, mode:thread, model: TIER_DEFAULT — --tier: no path-derived mandate (protocol addition; one CONTRACT_REVIEW_TIER review owed before the queue, run by the seat), effort: high
    Clause-②: yes
    Metadata-protocol: added
    Responsibility: the spec lane owns the template hole grammar (retriage 6452088840: item (b) is a protocol addition); the formula formatter, the mail renderer and the lint rule ride the same vertical PR
    Thread-read: 6452088840
    Premise: holds on origin/main c4a4aefc — after PR #26799 (dabe77182) a currency: argument must be a literal ISO 4217 code; a path such as currency:currency_code is refused (currency-code-literal, packages/formula/src/template-engine.ts ~:227), so no template can format an amount in the record's own currency
    Serial constraints cleared: no open PR touches formula's template engine, plugin-email, or the email-template lint rule (search 20:25Z: only #26861, unrelated region, and the version-packages PR); a skills/** edit, if any, makes this PR governed (Tier H); seat 1 told at 20:20Z

  12. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 26660,
    "item": "b",
    "status": "done",
    "branch": "claude/project-thread-wonj87",
    "pr": "#26937",
    "head": "511e09b12",
    "session": "session_01WGufziJbRgRHSN9KrbF46h",
    "premise_still_valid": true,
    "summary": "A mail template formats an amount in the record's own currency with currency:@path ({{ planned_amount | currency:@currency_code }}). Rule, one sentence, exported as CURRENCY_ARGUMENT_RULE and quoted by the spec email_template face descriptions, the lint hint and every renderer refusal: 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 is unambiguous against the literal for every input, so every #26799 refusal pin (currency:currency_code, currency:record.currency_code, eur, empty) stays green unchanged. Dialects: mail accepts; the in-app engine (flow text slots, action and bulk-action copy, titleFormat, promptTemplate) refuses it by name, new rule currency-code-path-mail-only, because its closed-scope root judges read a hole's path and never an argument. Save time: os validate / build / lint and the email_template publish door accept a well-formed marked path and, when the template declares variables, refuse an undeclared one (currency-code-path-undeclared, 422 INVALID_METADATA at the door). Render: plugin-email resolves the path; a missing, null, lowercase or other value throws TEMPLATE_INVALID naming template, locale, hole, path and value, never USD. Premise re-measured on 6090bb7 through EmailService.renderTemplate and the lint rule: the path form was refused at save (1 finding) and render (TEMPLATE_INVALID) for both 'eur' and 'EUR'.",
    "tests": "New: formula src/template-currency-code-path.test.ts 38 (incl. a spec-describe parity pin against CURRENCY_ARGUMENT_RULE); plugin-email src/template-currency-code-path.render.test.ts 12 (one bundle, en-US and zh-CN rows, EUR and CNY records: en-US €1,234.50 / CN¥1,234.50, zh-CN €1,234.50 / ¥1,234.50 in every face; unresolvable path, 'eur' and null each refused in both locales, TEMPLATE_INVALID prefix asserted, no $ in the message, nothing sent); lint src/validate-email-template-hole-grammar.currency-path.test.ts 13; metadata-protocol runtime-authoring-gate.email-template-writes.test.ts +1 (code INVALID_METADATA + status 422 + issue). #26799 pins unchanged and green. Full suites at 511e09b under os-verify-lock, one && chain, VERDICT command-exit 0: spec Test Files 738 passed (738) Tests 22101 passed 1 todo; formula 57/57, 1522; plugin-email 41/41, 724; lint 170/170, 7483; metadata-protocol 273 passed 4 skipped (277), 21694 passed 26 skipped; service-messaging 61/61, 734; typecheck exit 0 for all six. Same six green at d1fffa1 before the merge. eslint --no-inline-config on the 11 changed .ts files exit 0. Ablations via scripts/ablation-replace.mjs, each landed (anchor 1 -> 0, blob changed) and restored to the HEAD blob with git diff HEAD empty: renderer value guard dropped -> plugin-email 4 failed | 8 passed (the refusal pins; the unresolvable path then printed USD); in-app dialect check off -> formula 2 failed | 36 passed; declared-variable check off -> formula 1 failed | 37 passed. Suites under ablation read source (formula's own suite, plugin-email's renderer), so no dist leg; the lint and door legs read formula dist and were not ablated.",
    "gates": "Derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths), 114 commands, serial, exit captured before any pipe. At 511e09b: 114 :: exit 0; --ran: 114 derived, 114 run, 0 NOT-MEASURED. At d1fffa1: 112 exit 0, pnpm --filter @objectstack/spec run check:skill-examples :: exit 3 and pnpm check:dual-build-cjs-loads :: exit 3 (unbuilt), both exit 0 after pnpm build. Extras at d1fffa1: check:dev-prereqs 0, check:skill-frame-freshness 0, check-platform-checklist --changed-from origin/main 0, check:error-code-casing 0, check-skills-token-ratchet 0, spec check:generated 0 after --fix (content/docs/references/system/email-template.mdx) and 0 again after the merge. CI on PR #26937 at 511e09b at report time: Governed Surface Queue Guard success, Check PR Size success, PR-body guards success, filter success; Build Core, Check Changeset, Lint lanes, Type Check lanes, Plan Test Core shards, Spec property liveness, Dogfood Regression Gate, Build Docs: in_progress; Temporal Conformance, Dogfood shards, Dogfood Verify CLI, upgrade harness, tarball smoke: skipped.",
    "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
    "line_budget": "skills/objectstack-formula/SKILL.md 471 -> 473 lines, 5627 -> 5668 tokens (ceiling 5997, headroom 329 after); shipped SKILL.md set 4606 -> 4608 lines; bundle 213518 -> 213559 tokens. Within the ceiling, no ceiling raised. PR is Tier H (skills/**): maintainer approval owed before landing.",
    "declarations": "Clause-②: yes · Metadata-protocol: added (both copied from claim 6452747733 into the PR body and all four changesets; re-judged from the diff: a form refused at save and render until now becomes accepted, nothing narrows — the in-app dialect refused currency:@x before as currency-code-literal and still refuses it) · Skills-docs: follow-up owed — skills/objectstack-formula/evals/cel-predicates-formulas.json · ADR-0087: not-required — nothing stored changes meaning: the new form was refused at save and render until now, so no stored template or built artifact carries it",
    "files_changed": [
    ".changeset/26660-currency-code-path-formula.md",
    ".changeset/26660-currency-code-path-lint.md",
    ".changeset/26660-currency-code-path-plugin-email.md",
    ".changeset/26660-currency-code-path-spec.md",
    "content/docs/automation/email-templates.mdx",
    "content/docs/references/system/email-template.mdx",
    "packages/formula/src/index.ts",
    "packages/formula/src/mail-hole-grammar.ts",
    "packages/formula/src/template-currency-code-path.test.ts",
    "packages/formula/src/template-engine.ts",
    "packages/lint/src/validate-email-template-hole-grammar.currency-path.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-currency-code-path.render.test.ts",
    "packages/plugins/plugin-email/src/template-engine.ts",
    "packages/spec/src/system/email-template.zod.ts",
    "skills/objectstack-formula/SKILL.md"
    ],
    "deviations": [
    "File surface: packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts (+1 test, test only, no changeset) is beyond the claim's named files; it is the publish-door host pin the order's 'lint/publish-door tests' asks for, beside #26799's cases.",
    "No eval added (order: say what it should cover): see open_questions / the PR's Acceptance notes.",
    "One merge of origin/main (511e09b, via os-regen-merge.sh, no conflict) before opening the PR, because main moved spec, lint and metadata-protocol; rebuilt, re-ran the six suites and the 114 gates on the merge head.",
    "Branch is the platform-assigned claude/project-thread-wonj87 (as the claim states), reset to origin/main 6090bb7 before work; never pushed empty.",
    "Reviewer request for os-support-ai not made: the order forbids MCP writes and the relay pr_create carries assignees only; assignee os-support-ai read back. The relay's governed-review follow-up handles Tier H approvers.",
    "PR activity not subscribed: os-dev.md's dispatch contract overrides the platform's auto-subscribe posture; patch rounds come back through the seat."
    ],
    "mcp_calls": "6 — reads only: issue_read get x2 and get_comments x2 on #26660, pull_request_read get_comments on #26799, pull_request_read get_check_runs on #26937. No MCP write.",
    "api_writes": "2 — (1) relay pr_create (fleet-write dispatch fw-20261027T221554Z-bae3e6, run 40593465479: POST /repos/objectstack-ai/objectstack/pulls draft + POST /repos//issues/26937/assignees), body read back 15609 bytes identical; (2) this os-dev-report comment on #26660 via the relay comment op. git push not counted.",
    "open_questions": [
    {
    "question": "currency with no argument still formats in USD. Census at 6090bb7 (no-arg | currency holes, generated, CHANGELOG and release files excluded): examples 0, seeds 0, skills 0, apps 0; docs 2 (automation/flows.mdx:472, data-modeling/formulas.mdx:79, both in-app); spec describes/comments 4 (flow-text-slot-template.ts:23, io-node-config.zod.ts:161/:632/:686); service-automation 2 (notify-node.ts:810, builtin/template.ts:104); example prose 2 (formula invalid-hole message, migration 18.flow-text-slot-single-brace-refused.ts:24); tests pinning the USD output 4 (formula template-formatters.test.ts:19, template-hole-grammar.test.ts:114, plugin-email template-engine.test.ts:79, template-hole-grammar.render.test.ts:110) plus 3 fixtures carrying the hole. No shipped template relies on it.",
    "options": [
    "A: a separate narrowing card: refuse a no-argument currency at authoring (both dialects) with the prescription currency:USD (or, in mail, currency:@path); BREAKING with a one-line D3 note; migrate the 2 docs, 4 describes and 4 pins",
    "B: keep USD as the documented default (status quo)",
    "C: default to the tenant localization.currency (a semantic change; needs a context channel in both engines and the mail renderer)"
    ],
    "recommendation": "A, because an implicit USD is the silent default currency triage ruled out (6441491903), and nothing shipped depends on it; the work is small and mechanical. Seat rules."
    },
    {
    "question": "Eval owed (#26369 B), not in this PR: what it should cover.",
    "options": [
    "A: skills/objectstack-formula/evals/cel-predicates-formulas.json gains one case: 'write an email_template that shows the overdue amount in each contract's own currency' expecting {{ 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"
    ],
    "recommendation": "A, filed by the seat (it moves the same Tier H surface)."
    }
    ],
    "out_of_scope_findings": [
    "carrier: none · in-app dialects cannot read a currency code from data: accepting currency:@path there needs the closed-scope root judges (spec closedScopeTemplateRefusal, lint flow-path rules) to read an argument's path; noted in Acceptance notes, not filed (no defect: refused by name)"
    ]
    }


    Generated by Claude Code

  13. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #26937 comment 6456811179 · domain:spec seat 3 · 2026-10-28T00:05Z

    Contract review round 2 PASS (6456767827) on head 49258dec. Tier H, because the PR touches skills/**. It is waiting for an authorized approval from os-zhuang or hotlong, and needs-user-decision is on the PR. The seat lands it after the approval. Item (b) of this card is in this PR; item (c) landed in #26799.


    Generated by Claude Code

  14. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:spec seat 3 · os-support-ai · 2026-10-28T13:35Z

    reading value
    PR #26937 approved by os-zhuang (Tier H) and merged at 2026-10-28T09:58Z, merge commit 2205863525
    origin/main contains the merge commit (git merge-base --is-ancestor)

    Delivered (item (b); item (c) landed in #26799):

    • A mail template formats an amount in the record's own currency with {{ amount | currency:@path }}. The code is read from the send data, and a missing, null or lowercase code renders TEMPLATE_INVALID. It never falls back to US dollars.
    • The in-app template dialects (flow notifications, action copy) refuse currency:@path by name. Every form accepted before renders as it did.

    Owed elsewhere: #26940 (skills follow-up), #26939 (a no-argument currency still formats in USD; spec seat 1), #27225 (non-word formatter text in mail holes). pm:dispatched is removed here; the card is closed by the merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:i18nThe customer's own language, across UI, metadata and notificationsdomain:specenhancementNew feature or requestpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions