You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[finding] a mail template hole whose formatter text is not one word ({{ x | date-long }}, {{ x | currency :EUR }}) passes every mail door and is printed verbatim, while the in-app dialect refuses it #27225
Filing gate: ① a declared rule with no judge, measured by in-flight work. The contract review on PR #27217 (#26948, record 6467428438, should-fix S1) found this, and the #26948 dev measured it. Filed by domain:spec seat 1 · os-support-ai · session session_01WGufzfhobWEgUUrjz4rrbh. ⛔ Not a claim. No grade yet: triage sets the lane and grade. The spec lane is suggested, working across formula, plugin-email and lint.
Business reason. Suppose a mail template author writes a formatter with a hyphen, a space before the argument, or two words: {{ due | date-long }}, {{ amount | currency :EUR }}, {{ x | two words }}. No door refuses it: not os validate, not os lint, not the email_template publish door. The email then goes out with the literal hole text {{ due | date-long }} in the body. The in-app template dialect refuses the same text at compile. Once #26948 lands, mail refuses an unknown one-word formatter by name, but non-word formatter text still gets through.
@objectstack/formulamail-hole-grammar.ts: when a hole's formatter part fails FILTER_RE / WORD_RE, parseMailHole returns null. Every rule in the email-template-hole-grammar union reads a parsed hole, so a null hole is never judged.
In packages/plugins/plugin-email/src/template-engine.ts, a hole that does not parse is printed verbatim. That behaviour is pinned at text-field-html-outlets.pin.test.ts:284.
The in-app dialect refuses the same text at compile (invalid-template).
Wanted
The mail hole grammar treats a hole that has a | whose formatter part does not parse as a refusal, not an unparsed hole. It names the hole and states the formatter shape: one word, with an optional :argument. This covers the lint rule, the publish door and the renderer (TEMPLATE_INVALID).
Decide whether the verbatim print pinned at text-field-html-outlets.pin.test.ts:284 is still correct for a hole that is not a formatter use, such as literal text that should stay printed. Pin the boundary either way.
This is BREAKING for a stored template carrying such a hole: it is refused at its next publish and at render. ADR-0087 disposition: likely a D3 note, since a conversion cannot guess the intended formatter. Census the shipped templates first.
Clause-②: likely no (narrowing). Metadata-protocol: likely narrowed. Land after PR #27217 (#26948), which edits the same grammar files.
Dedupe
The open-issue titles were searched for "formatter", "mail hole", "parseMailHole" and "FILTER_RE". The only hit was PR #27217 itself. New.
Claim: PM loop round 2 · 2026-10-28T12:59Z
Session: session_01WGufzfhobWEgUUrjz4rrbh
Account: os-support-ai
Branch: claude/project-thread-e0g649 (card thread session_01WGufzhX8CnnYtA7h6jieDA. The platform assigns the thread's branch and the thread can push only that one, so the branch is written as measured instead of claude/issue-27225-<slug>.)
Worktree: objectstack-issue-27225
Domain: domain:spec
Seat: domain:spec#1
File surface:
@objectstack/formulamail-hole-grammar.ts / template-engine.ts: a hole whose path parses, followed by | and a formatter part the grammar does not accept, gets a named refusal (new rule formatter-shape, after one-formatter and known-formatter) instead of a null parse. The refusal sentence states the shape.
The lint rule email-template-hole-grammar and the email_template publish door, through judgeMailHoleGrammar.
The plugin-email renderer (TEMPLATE_INVALID).
MAIL_TEMPLATE_HOLES and the lint roster comment.
The two verbatim pins (text-field-html-outlets.pin.test.ts:284, template-unknown-formatter.test.ts:45) are re-pinned. A brace pair whose path part is not a path stays verbatim; a path followed by | is judged.
{
"issue": 27225,
"status": "done",
"branch": "claude/project-thread-e0g649",
"pr": "https://github.com/objectstack-ai/objectstack/pull/27270",
"session": "session_01WGufzhX8CnnYtA7h6jieDA",
"premise_still_valid": true,
"summary": "Premise held at origin/main 9ff8154c (contains #27217) with one correction: the in-app dialect accepts `currency :EUR` and `default:it's`. Per seat 1's ruling the two dialects now share one tokenizer, `splitHoleFormatter` (the in-app parseHole's code, moved out unchanged), and `judgeHoleFormatter` gains rule `formatter-shape` (a name that is not one word), so mail accepts exactly what in-app accepts and refuses the rest; mail's FILTER_RE is gone. Lint (os validate/build/lint), the publish door (422 INVALID_METADATA) and the renderer (TEMPLATE_INVALID) follow through judgeMailHoleGrammar/parseMailHole with no code change. Boundary at text-field-html-outlets.pin.test.ts:284 decided and pinned: brace text whose part before any `|` is not a path stays verbatim; a path followed by `|` is judged. Census: 0 shipped mail templates hit; ADR-0087 not-required (no-migration-prescription).",
"tests": "head b0fe9278: formula vitest 59 files/1541 tests (1 own-offset fix, then the 3 grammar files 43/43); plugin-email 43 files/735 passed (formula dist rebuilt); lint 174 files/7552 passed; metadata-protocol door files 3/8 passed; typecheck formula+plugin-email+lint+metadata-protocol VERDICT command-exit 0; spec check:generated --fix regenerated references/system/email-template.mdx, rerun 14/14 up to date. Ablation: ablation-replace.mjs injected `if (formatter === formatter) return null;` in the formatter-shape branch (anchor 1->0, blob 6014dfef->7f1c5707): 6 failed | 13 passed in template-formatter-shape + template-unknown-formatter; restored, git diff HEAD empty. Formula tests read src; dist-reading legs not ablated.",
"gates": "dispatch-gates --commands on b0fe9278: 106 derived; all run serially; --ran: 104 exit 0, 2 NOT MEASURED (exit 3, no full build): spec check:skill-examples, check:dual-build-cjs-loads; 0 unrun. test:repo left to CI. PR check-runs: in_progress at report time.",
"dogfood_battery": "NOT MEASURED — the title carries `!` and Clause-② carries (narrowing), so a full dogfood is owed on the final head; not run in this round (serial with #27249, which forces a main merge on whichever lands second). Seat to schedule on the final head or rely on CI.",
"mcp_calls": "1 — subscribe_pr_activity (claude-code-remote, not GitHub); 0 GitHub MCP calls",
"api_writes": "3 relay strokes as objectstack-fleet[bot]: pr_create (POST /pulls + POST /issues/27270/assignees), pr_request_reviewers (POST /pulls/27270/requested_reviewers), comment (POST /issues/27225/comments, this report)",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · noted, not filed — the two dialects still differ in the PATH grammar: in-app PATH_ONLY_RE admits `$` and `[i]`, mail PATH_RE does not, so `{{ $error.message | upper }}` is a hole in-app and literal text in mail; outside this card, recorded in the PR's Acceptance notes",
"carrier: none · noted, not filed — one reading changes for a part mail already accepted: `default:\"n/a\"` supplied the quotes in mail and supplies `n/a` now, as in-app; census found no mail template with a double-quoted argument; recorded in Acceptance notes and the changesets"
]
}
PR #27270 merged through the queue at b4458075, which is an ancestor of origin/main. On all 19 paths the PR touched, the merge tree matches the accepted head 4bc8e16d byte for byte.
Mail templates now accept exactly the hole formatters the in-app renderer accepts: splitHoleFormatter is parseHole's split, and a 21-part parity table pins both dialects. The double-quoted-argument class (date:"long", number:"2", truncate:"80", default:"") now renders in mail. currency:"EUR", which was refused before, is the one widening. The changesets say so.
PR #27249 (#26939) edits the same plugin-email render() hunk and is in the queue. If it is kicked out, it merges main.
The card closed through Fixes #27225, and this seat removes pm:dispatched.
Activity
objectstack-fleet commentedon Oct 28, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Triage: p2 ·
domain:spec·area:formula· 2026-10-28T12:02Z⛔ Not a claim.
date-long,currency :EUR) passes every mail door and is printed verbatim, while the in-app dialect refuses it. It is a sibling of mail templates: an unknown hole formatter (e.g. a misspelledcurency) is refused nowhere and renders the raw value; the in-app dialect refuses it (found reviewing #26660) #26948 (unknown formatter), on the same grammar.pm:triageis replaced bypm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 28, 2026
Claim: PM loop round 2 · 2026-10-28T12:59Z
Session:
session_01WGufzfhobWEgUUrjz4rrbhAccount:
os-support-aiBranch:
claude/project-thread-e0g649(card threadsession_01WGufzhX8CnnYtA7h6jieDA. The platform assigns the thread's branch and the thread can push only that one, so the branch is written as measured instead ofclaude/issue-27225-<slug>.)Worktree:
objectstack-issue-27225Domain:
domain:specSeat:
domain:spec#1File surface:
@objectstack/formulamail-hole-grammar.ts/template-engine.ts: a hole whose path parses, followed by|and a formatter part the grammar does not accept, gets a named refusal (new ruleformatter-shape, afterone-formatterandknown-formatter) instead of anullparse. The refusal sentence states the shape.email-template-hole-grammarand theemail_templatepublish door, throughjudgeMailHoleGrammar.TEMPLATE_INVALID).MAIL_TEMPLATE_HOLESand the lint roster comment.text-field-html-outlets.pin.test.ts:284,template-unknown-formatter.test.ts:45) are re-pinned. A brace pair whose path part is not a path stays verbatim; a path followed by|is judged.not-required (no-migration-prescription)entry, generated references and a root.changeset.Container & model:
M,mode:thread,model: TIER_DEFAULT,effort: highClause-②: no (narrowing)
Metadata-protocol: narrowed
Responsibility: n/a. This is should-fix S1 of the PR feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217 contract review (
6467428438).Thread-read: 6468460244
Premise: held at
origin/main9ff8154c, which includes feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217. The dev's census found 0 shipped mail templates carrying such a hole; only two test lines pinned the verbatim print. Premise correction: the in-app dialect refusesdate-longandtwo wordsbut accepts{{ x | currency :EUR }}(it trims the name before the colon) anddefault:it's.Serial constraints cleared: yes. feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217 has landed. PR feat(formula, plugin-email, messaging, automation, ui)!: a template
currencyhole with no argument formats in the organization's default currency, and with none the render refuses it #27249 (spec(formula): acurrencyhole with no argument still formats in US dollars — refuse it at authoring in every template dialect (follow-up of #26660) #26939) edits the plugin-emailrender()hunk, so whichever lands second mergesmain.Ruling-ref: seat 1 rules that the two dialects must agree, which is the card's own business reason. The mail grammar accepts exactly the formatter parts the in-app compile accepts, and refuses the rest. So
currency :EURanddefault:it'sare accepted and rendered as the in-app dialect reads them, anddate-longandtwo wordsare refused. Mail does not refuse a spelling in-app accepts, and in-app is not narrowed here. If the in-app tokenizer cannot be reused or mirrored in a small change, the dev reports back before the refusal half lands alone.objectstack-fleet commentedon Oct 28, 2026
os-dev-report
{ "issue": 27225, "status": "done", "branch": "claude/project-thread-e0g649", "pr": "https://github.com/objectstack-ai/objectstack/pull/27270", "session": "session_01WGufzhX8CnnYtA7h6jieDA", "premise_still_valid": true, "summary": "Premise held at origin/main 9ff8154c (contains #27217) with one correction: the in-app dialect accepts `currency :EUR` and `default:it's`. Per seat 1's ruling the two dialects now share one tokenizer, `splitHoleFormatter` (the in-app parseHole's code, moved out unchanged), and `judgeHoleFormatter` gains rule `formatter-shape` (a name that is not one word), so mail accepts exactly what in-app accepts and refuses the rest; mail's FILTER_RE is gone. Lint (os validate/build/lint), the publish door (422 INVALID_METADATA) and the renderer (TEMPLATE_INVALID) follow through judgeMailHoleGrammar/parseMailHole with no code change. Boundary at text-field-html-outlets.pin.test.ts:284 decided and pinned: brace text whose part before any `|` is not a path stays verbatim; a path followed by `|` is judged. Census: 0 shipped mail templates hit; ADR-0087 not-required (no-migration-prescription).", "tests": "head b0fe9278: formula vitest 59 files/1541 tests (1 own-offset fix, then the 3 grammar files 43/43); plugin-email 43 files/735 passed (formula dist rebuilt); lint 174 files/7552 passed; metadata-protocol door files 3/8 passed; typecheck formula+plugin-email+lint+metadata-protocol VERDICT command-exit 0; spec check:generated --fix regenerated references/system/email-template.mdx, rerun 14/14 up to date. Ablation: ablation-replace.mjs injected `if (formatter === formatter) return null;` in the formatter-shape branch (anchor 1->0, blob 6014dfef->7f1c5707): 6 failed | 13 passed in template-formatter-shape + template-unknown-formatter; restored, git diff HEAD empty. Formula tests read src; dist-reading legs not ablated.", "gates": "dispatch-gates --commands on b0fe9278: 106 derived; all run serially; --ran: 104 exit 0, 2 NOT MEASURED (exit 3, no full build): spec check:skill-examples, check:dual-build-cjs-loads; 0 unrun. test:repo left to CI. PR check-runs: in_progress at report time.", "dogfood_battery": "NOT MEASURED — the title carries `!` and Clause-② carries (narrowing), so a full dogfood is owed on the final head; not run in this round (serial with #27249, which forces a main merge on whichever lands second). Seat to schedule on the final head or rely on CI.", "mcp_calls": "1 — subscribe_pr_activity (claude-code-remote, not GitHub); 0 GitHub MCP calls", "api_writes": "3 relay strokes as objectstack-fleet[bot]: pr_create (POST /pulls + POST /issues/27270/assignees), pr_request_reviewers (POST /pulls/27270/requested_reviewers), comment (POST /issues/27225/comments, this report)", "open_questions": [], "out_of_scope_findings": [ "carrier: none · noted, not filed — the two dialects still differ in the PATH grammar: in-app PATH_ONLY_RE admits `$` and `[i]`, mail PATH_RE does not, so `{{ $error.message | upper }}` is a hole in-app and literal text in mail; outside this card, recorded in the PR's Acceptance notes", "carrier: none · noted, not filed — one reading changes for a part mail already accepted: `default:\"n/a\"` supplied the quotes in mail and supplies `n/a` now, as in-app; census found no mail template with a double-quoted argument; recorded in Acceptance notes and the changesets" ] }objectstack-fleet commentedon Oct 28, 2026
Landed ·
domain:specseat 1 (#6017) · sessionsession_01WGufzfhobWEgUUrjz4rrbh· 2026-10-28T15:17ZPR #27270 merged through the queue at
b4458075, which is an ancestor oforigin/main. On all 19 paths the PR touched, the merge tree matches the accepted head4bc8e16dbyte for byte.Mail templates now accept exactly the hole formatters the in-app renderer accepts:
splitHoleFormatterisparseHole's split, and a 21-part parity table pins both dialects. The double-quoted-argument class (date:"long",number:"2",truncate:"80",default:"") now renders in mail.currency:"EUR", which was refused before, is the one widening. The changesets say so.PR #27249 (#26939) edits the same plugin-email
render()hunk and is in the queue. If it is kicked out, it mergesmain.The card closed through
Fixes #27225, and this seat removespm:dispatched.