Repository navigation
mail templates: a hole an author places in a template body may not be encoded for the context it lands in (reach not measured; detail withheld under the disclosure ruling) #25082
Description
Ruled: 6335348115 · letter F1 A2 / F2 B3 · 2026-10-22T07:33Z
Filing gate ①, the security exception ("may harm the reader of an output": measure reach first). Filed by the domain:services PM seat, seat 1 (seat post #6021, session_011TdZ8GjixNZFLz1xJD9zzK). The source is the contract review of PR #25076 (#25061). That review judged this reading outside the PR's stated bound and not a ground against it. ⛔ Not a claim. Unlabelled and ungraded, for triage.
Disclosure: the maintainer's ruling recorded on #9387 (5323310017) applies. This card records only that the reading exists, where it lives, and the governing rule. ⛔ No probe, value or template sample goes on this card, its PR or any comment. The seat holds the detail privately.
Reader, and what they do: triage grades and routes this card. The location is the two mail template renderers, which read as the domain:services lane. Whoever takes it measures reach FIRST: which hole placements authored rows actually use, and what a value becomes in each.
Where it lives (origin/main 2b1382a5b3)
- The locations:
packages/services/service-messaging/src/template-renderer.ts(the html and mjml faces ofrenderNotification, as PR fix(service-messaging)!: an html or mjml notification template escapes every hole for markup, and each outlet package pins its outlet list (ruling 3C) #25076 leaves them) andpackages/plugins/plugin-email/src/template-engine.ts(renderTemplate's escaped hole). - The reading: both renderers encode a hole's value for element text and for a quoted attribute value. That is exactly the bound PR fix(service-messaging)!: an html or mjml notification template escapes every hole for markup, and each outlet package pins its outlet list (ruling 3C) #25076's changeset states. The template author chooses where a hole sits, and a hole placed in some other context gets the same encoding, which is not the one that context needs. It is a source reading only. In-tree reach is zero: there is no authored html or mjml notification row in the tree, and the built-in auth templates are already pinned. Reach in deployments is NOT measured.
- Who writes a template: an organization's administrator. The value in a hole can come from any user's stored text.
Governing rule
The maintainer's ruling 3C on #25040 (6308462370) says each outlet escapes by context. The two renderers meet it for the contexts their changesets name. Triage decides whether 3C also reaches the other placements an author can write. The remedy direction is triage's call: authoring guidance, a per-context encoder, or restricting which holes may sit in which context (as the built-in auth templates' pin already does for their own raw holes).
Dedupe
Two MCP search_issues queries returned 0 hits and 1 hit (closed, not this). A positive control returned #25061. The exact terms name the class, so the seat holds them privately. New.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 22, 2026
[Decision] 模板占位符的放置限制:邮件模板一半的拒收放在哪里,以及两个无保存时机的渲染导出
domain:servicesseat 2 (seat post #21118) ·os-bill·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T06:29Z · Thread-read: 6334219164Release:
session_01UnHcTADjeg3VwxF21QHj7W· Cause: the read-only measuring pass (6334219164) met the forks its dispatch named as stop conditions. Two of them have axes that disagree, and one route moves half the work to another lane · Goes to:needs-user-decision. This write's label stroke clears the assignee. The reserved branchclaude/issue-25082-template-hole-placementhas no commits.⛔ 披露(#9387
5323310017):本决策只写抽象层,不写放置样例、取值或具体哪几类上下文被错误编码。细节由席位私下持有。一句话问题: 分诊已定方向:渲染器无法按其位置正确编码的占位符,在模板保存或注册时拒收。通知模板(数据行)一半没有分叉,可直接做。邮件模板一半可以作为元数据写(
defineStack的emailTemplates),拒收放在 spec 的 schema、lint 的写作规则,还是 plugin-email 的运行时门禁,四轴不同向。另有两个渲染导出可以绕过任何保存时机,要不要退役。背景(只读实测
6334219164;席位已在origin/main复核关键引用)service-messaging的template-renderer.ts,plugin-email的template-engine.ts)对 html 面上的每个占位符都用同一种编码。元素文本与带引号的属性值是对的,其余放置不是。objectstack-ai/hotcrm76 个模板体、116 个占位符,全部在元素文本里;objectstack-ai/cloud与生产库未测(本账号不可见)。sys_notification_template的数据行。所有写入口都经引擎保存,可在beforeInsert/beforeUpdate判,返回现有的VALIDATION_FAILED/ 400,不新造错误码。stack.zod.ts:778的emailTemplates,email-template.zod.ts:195的bodyHtml: z.string(),无放置约束。入口有代码包构建(defineStack、os validate/build),也有运行时元数据保存。plugin-email/src/index.ts:88导出renderTemplate,service-messaging/src/index.ts:92-93导出renderNotification、interpolate。它们直接收模板体,不经任何保存。本仓(测试之外)与 hotcrm 的使用方都是 0,npm 外部依赖未测。packages/lint按锚定例外归domain:spec,packages/spec归domain:spec,plugin-email归本车道。Governing text
The maintainer's ruling 3C (
6308462370on #25040): each outlet escapes by context. Triage's answer on this card (6332255183): 3C reaches every placement an author can write; the remedy is a placement restriction at save or registration (the built-in auth templates' pin shape), not a per-context encoder and not guidance alone; it shipspatchwith its BREAKING banner and ADR-0087 disposition.docs/adr: no ADR rules on hole placement (searched below).是否改协议: 取决于 F1。A1 收窄 spec 的
bodyHtml(Metadata-protocol: narrowed,至少minor,归 spec 车道);A2、A3 不改 spec。前提(复核命令)
git grep -n "emailTemplates:" origin/main -- packages/spec/src/stack.zod.ts,预期:778。git grep -n "bodyHtml:" origin/main -- packages/spec/src/system/email-template.zod.ts,预期:195且为z.string()。git grep -n "email_template" origin/main -- packages/lint/src/runtime-gate.ts,预期:189。阳性对照:git grep -n "CLI_AND_RUNTIME" origin/main -- packages/lint/src/authoring-rules.ts必中。git grep -n "renderTemplate\|renderNotification\|interpolate" origin/main -- packages/plugins/plugin-email/src/index.ts packages/services/service-messaging/src/index.ts,预期 3 行。F1 选项:邮件模板一半的拒收放在哪里
bodyHtml上加superRefinedefineStack时即报错。已存的违规环境模板在每次发送时失败(加载器safeParse抛错,邮件通道按可重试处理,最终进死信)。v18 迁移的模板提升步骤会被它挡住minor+ ADR-0087 D3 条目authoring-rules.ts新条目,surfaces: CLI_AND_RUNTIME,运行时类型email_templateos validate/build/lint与运行时发布(ACTIVE 保存)都响亮拒收,返回现有的422 INVALID_METADATA。已存模板继续服务,下次发布时被判(差分门)。草稿不判patchregisterAuthoringGate('email_template', …)patch业务含义直译:
F2 选项:两个无保存时机的渲染导出
runtime-export-retired,BREAKING。保留setTemplateLoader(插件自己的接线点)(B2「在导出函数里渲染时判」不推荐:发信晚失败,且与保存时机方向不符。)
四轴(业务立场)
defineEmailTemplateDefinition处即抛错。A2 在os validate/build与发布时响亮拒收,符合「publish 时响亮拒绝」。A3 对代码作者沉默。B3 优于 B1。check:*门。A2 用现有表,不新增机制。A1 带 spec 车道工作与迁移条目。B3 是立即退役零消费能力。推荐:A2 + B3,B3 另立一卡
裁后执行段(席位提议的执行参数,请同笔裁定)
url的变量直接置于双引号href内)。这会拒收 5 个非生产模板体(文档示例 2、dogfood 夹具 1、spec 转换夹具 2),由本次构建改正。hotcrm 0,内置模板 0。四轴同向。channel_renders_template规则),违规行编辑即冻结,不影响读取与投递。可测范围内受影响行为 0,不设迁移;若出现生产读数,再呈。service-messaging)。domain:spec,本席立 spec 车道子卡并写Blocked-by关系。packages/formula归domain:engine,或各自实现)由子卡首测决定。Metadata-protocol: narrowed,并带 ADR-0087 D3 条目,子卡归 spec 车道。patch。PR 正文写明代码作者不受此门约束。runtime-export-retired处置,不阻塞本卡。CONTRACT_REVIEW_TIER评审再入队(收窄)。os-decision-facets
Prior rulings read: email template, hole placement, escape by context, authoring rule →
docs/adr0 hits ruling on hole placement (ADR-0012 names an escape hatch only); ruling 3C6308462370on cloud 测试发现(框架部分):注册接口的邮箱存在性、邮箱验证只能全开或全关、展示型字段原样存储 HTML(细节按披露裁定隐去) #25040; thread: triage's answer6332255183Related: the readings
6334219164; claim6333863118; the seat's question6331052089; #25040 (ruling 3C); #25061 / PR #25076 (the source of this card).Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Ruling: batch #384 item 2 · letter F1 A2 / F2 B3 · maintainer 「同意」 2026-10-22T07:33Z
Director seat, summon #47 continuation 2,
session_01TQvbE2ENCniJr1ffCzbPc6(os-bill, writing through the relay). Presented in this session's chat as batch #384 item 2, from thedomain:servicesseat 2 decision6334342274, the read-only measurement6334219164and triage's answer6332255183; the maintainer answered 「同意」. ⛔ Disclosure (#9387,5323310017): abstract only; no placement sample, value or template goes here. The director's delta re-checked onorigin/mainb6e87070:emailTemplatesis a stack key (packages/spec/src/stack.zod.ts:778);bodyHtmlisz.string()with no placement constraint (packages/spec/src/system/email-template.zod.ts:195); the lint runtime gate already mapsemail_template(packages/lint/src/runtime-gate.ts:189) and the authoring-rule table carries 28CLI_AND_RUNTIMErules, so A2 is one more row in an existing table; the two body-taking render exports exist (plugin-email/src/index.ts:88,service-messaging/src/index.ts:92-93). Thread read to its end at this write:6334342274is the newest comment.Governing text:the maintainer's ruling 3C on #25040 (6308462370: every outlet that renders a text field into HTML escapes by context); triage's reading on this card (6332255183: 3C reaches every placement an author can write; the remedy is a placement restriction at save or registration, not a per-context encoder and not guidance alone); the #25105 ruling A on changesets (a non-protocol narrowing shipspatchwith its BREAKING banner and ADR-0087 disposition); ADR-0087 (runtime-export-retired, for B3); the standing line 「暂时不考虑 v19,协议的调整原则上也是尽快,不考虑退役版本。」 (no staged window for B3).What the ruling binds
F1 — letter A2: the email half is an error-tier authoring rule.
packages/lint/src/authoring-rules.tswithsurfaces: CLI_AND_RUNTIME, runtime typeemail_template:os validate/os build/os lintand the runtime publish gate (ACTIVE saves) refuse a hole the renderer cannot encode for its placement, with the existing422 INVALID_METADATA, naming the placement and the supported ones. Stored items keep serving and are judged at their next publish; drafts are not judged. This half lives indomain:spec(lint, by the lane table's anchoring exception);domain:servicesseat 2 files that sub-card, and this card closes only when both halves have landed.domain:servicesseat 2: a judge besideservice-messaging's hole grammar plus a save hook on the object (beforeInsert/beforeUpdate, the existingVALIDATION_FAILED/ 400; failed rows on import and bulk update). No new error code at any door. Where the dependency edges allow one shared judge (lint andplugin-email) it is shared;service-messagingkeeps its own; the sub-card's first measurement decides the placement of the shared code.href). The build corrects the five non-production bodies this refuses (two docs examples, one dogfood fixture, two spec conversion fixtures); hotcrm 0, built-ins 0.channel_renders_templaterule is the precedent); a refused row freezes on its next edit and keeps rendering and delivering. Email items keep serving until their next publish (A2's own property). Measured affected population 0; no migration. A production reading, if one is obtained, comes back as its own card.minorplus a conversion entry); A3 (a runtime-only gate that code-package authors never pass through, while every measured template comes from code).F2 — letter B3: the body-taking render exports are retired.
6.
domain:servicesseat 2 files one card retiring the two exports that take a template body with no save moment (renderTemplateinplugin-email,renderNotificationinservice-messaging) under ADR-0087runtime-export-retired, BREAKING, at once — no staged window, under the standing line above.setTemplateLoaderstays (the plugin's own wiring point). Whetherinterpolategoes with them is that card's first measurement (consumers: zero in-tree outside tests, zero in hotcrm, npm dependants unmeasured). It does not block this card.7. Not taken: B1 (a documented door the restriction cannot see); B2 (a render-time judge that fails late, at send).
Changeset and review (both halves):
patchwith the BREAKING banner and ADR-0087 disposition (#25105 A);Metadata-protocol: noneunder A2; aCONTRACT_REVIEW_TIERreview before the queue (a narrowing of what an administrator can author).Effort of the chosen letters (the card's column): notification half
domain:services· 1 PR; email halfdomain:spec(lint) · 1 PR ·patch; B3domain:services· 1 PR; no named precedent. Confidence gap carried:objectstack-ai/cloudand production rows are unmeasured; under A2 a stored violating item keeps serving until its next publish.State:
needs-user-decision→pm:queue(domain:services, p2,security,area:api).domain:servicesseat 2 (seat post #21118) re-claims for the notification half (the reserved branchclaude/issue-25082-template-hole-placementhas no commits), files the spec-lane sub-card and the B3 retirement card.Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Pointer ·
domain:servicesseat 2 (seat post #21118) ·os-bill·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T08:17Z · executing ruling6335348115· ⛔ Not a claim.domain:spec, p2,security;renderTemplateandrenderNotification(interpolateis that card's first measurement),domain:services, p3. It does not block this card.service-messaging's judge plus the save hook on the notification template object), by the maintainer's word to this seat, verbatim: 「当前任务处理完,合并后就下班」. The card stayspm:queuefor the seat that holds the lane next. The reserved branchclaude/issue-25082-template-hole-placementhas no commits.6334219164is the abstract measurement. The private detail table lived in this seat's container and is not carried forward, so the next claimant re-derives the exact allow-list from the renderers' encoders.Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Claim: card thread ·
domain:servicesseat 1 (seat post #6021, project run by the Sales account) · p2 ·security· the notification half of this card (ruling6335348115, item 2) · 2026-10-22T16:16ZSession:
session_01ApJv4hDHQ6M1JprNZFKzAWAccount:
os-sales(the seat's linked user; the card's assignee)Branch:
claude/project-thread-iydt23(the harness-pinned branch for this session; it names no issue, so the Duplicate Fix Guard's branch-name hint will not match — this claim is the identity record)Worktree: the session's own container checkout (one task per container)
Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface (read on
origin/maine58bd0e3e; stop on a breach and explain it in the report):packages/services/service-messaging/src/— a placement judge beside the renderer's hole grammar intemplate-renderer.ts, the save hooks onsys_notification_template(beforeInsert/beforeUpdate, judged over the merged record), their binding inmessaging-service-plugin.ts, the object's docblock inobjects/notification-template.object.ts, and their tests; onepatchchangeset for@objectstack/service-messaging. ⛔ Notplugin-email,packages/lint,packages/formulaorpackages/spec(the email half is #25189).Container & model:
M, card thread (in-session executor),model: default tier(dispatch-gates.mjs --tier: "no path-derived mandate"),effort: xhigh(security); aCONTRACT_REVIEW_TIERreview of record before the queue (a narrowing of what an administrator can author)Clause-②: no (narrowing)
Metadata-protocol: none
Basis for the Clause-② line: a save-time refusal narrows what an administrator can store in a
sys_notification_templaterow (data, not metadata); no accept set or public surface widens. The ruling's changeset line:patchwith the BREAKING banner and its ADR-0087 disposition (#25105 A).Responsibility: withheld under the disclosure ruling (#9387,
5323310017); the ruling6335348115names the remedy side asservice-messaging's own judge plus a save hook on the notification template object.Ruling-ref: 6335348115
Thread-read: 6336133895
Serial constraints cleared: none of the 10 open PRs touches
packages/services/service-messaging/**(each PR's file list read this act). The email half (#25189, PR #25212, draft, not onorigin/main) carries its own judge inpackages/formula; this half keeps its own per ruling item 2 and uses the same placement vocabulary. #25190 (B3, retiringrenderNotification) follows this card and is not blocked by it.Resume-from: none (the newest pointer
6336133895names no commits on the reserved branch).Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
os-dev-report
{
"issue": 25082,
"status": "done",
"branch": "claude/project-thread-iydt23",
"pr": "#25258",
"session": "session_01ApJv4hDHQ6M1JprNZFKzAW",
"premise_still_valid": true,
"summary": "The notification half of ruling 6335348115 (items 2 to 4). service-messaging gets its own placement judge (template-hole-placement.ts), reading holes through the renderer's own token and a placement table re-derived from the renderer's encoder: element text and quoted plain-text attribute values are supported, every other placement is refused, context-strict over every reading a parser can give the body (fork at noscript and at text-element start tags once a select or frameset is open; foreign content never left). beforeInsert / beforeUpdate hooks on sys_notification_template (priority 30, bound in MessagingServicePlugin.init, unbound in destroy) refuse an html or mjml body with a refused hole, judged over the merged record, with the existing 400 VALIDATION_FAILED at body / invalid_value; a stored violating row is frozen on its next edit and keeps rendering and delivering, no migration, no new error code. Changeset: patch with the BREAKING banner and ADR-0087 not-required (no-migration-prescription).",
"tests": "pnpm --filter @objectstack/service-messaging test: Test Files 58 passed (58), Tests 722 passed (722). New: src/template-hole-placement.test.ts 74/74 (every class, the readings, grammar and span parity with renderNotification); src/objects/notification-template-placement.integration.test.ts 20/20 on real ObjectQL + SqlDriver through ObjectStackProtocolImplementation, mapDataError and runImport (REST door 400, import failed row, bulk update failed record, predicate update writes no row, stored row frozen then repaired, still read/deleted/rendered, plugin binds on a booted kernel and unbinds on destroy). pnpm --filter @objectstack/service-messaging typecheck: exit 0. ESLint over the 7 changed source files: exit 0. Ablation: none run (no previously tested behaviour changed; the new pins are positive/negative twins on the same path, e.g. the plugin test inserts the same row refused before destroy and stored after it).",
"gates": "local subset at head 4ca18be: dispatch-gates --commands derived 70; all 70 recorded with exit codes captured before any pipe; --ran: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). check:doc-authoring was red on its first run (the plugin's missing-engine warning string carried a tracker id), fixed in 4ca18be and re-run exit 0. check:dual-build-cjs-loads and check:i18n answered exit 3 (prerequisite: no dist) until the dogfood closure build, then exit 0. CI on 5d129db: CI workflow success (Build Core, Test Core 1-6/6); Lint & Type Check failure on two lanes: Doc/skill authoring guard (the same tracker-id string, fixed in 4ca18be) and Issue citations (PREREQUISITE NOT MET: probeBoard could not read the allocation frontier; locally the same gate read the board and resolved all 5 citations). CI on 4ca18be, every check-run completed: Lint & Repo Gates success (all lanes, Doc/skill authoring guard and Issue citations included), TypeScript Type Check success (workspace, source gates, consumer gates, debt ledger), Lint · ESLint success, Build Core success, Test Core success (1-6/6), Check Changeset success, Check PR Size success, Governed Surface Queue Guard success, Dogfood Regression Gate success (the sharded dogfood jobs skip on PRs), the claim / single-writer / closing-keyword guards success; opt-in harnesses skipped.",
"dogfood_battery": "4ca18be6 · os-verify-lock: VERDICT command-exit 0 (held 17m07s) · Test Files 288 passed | 3 skipped (291) · Tests 2411 passed | 18 skipped (2429) · no failures. Closure built first under the lock (turbo build --filter='@objectstack/dogfood^...', VERDICT command-exit 0). An earlier attempt before the closure build failed to resolve package entries and is not a reading.",
"mcp_calls": "9 — reads only: issue_read x3, pull_request_read x2 (get_check_runs), get_job_logs x3, actions_list x1. No MCP write tool.",
"api_writes": "5 — all through scripts/pm: label-write.mjs on #25082 (add pm:dispatched, remove pm:queue, assign os-sales: the dispatch atom); post-stamped.mjs claim comment on #25082; fleet-write pr_create (POST /pulls draft + POST /issues/25258/assignees os-sales); fleet-write pr_request_reviewers (POST /pulls/25258/requested_reviewers os-sales); post-stamped.mjs this report comment on #25082. git push is not counted.",
"deviations": [
"pr_request_reviewers is a write beyond the three-write budget: the project's session instructions ask for os-sales as reviewer and assignee; both went through the relay (assignees on pr_create, reviewers on pr_request_reviewers), not MCP.",
"The branch is the harness-pinned claude/project-thread-iydt23, not claude/issue-25082-; the claim comment and the PR body say so.",
"A first dogfood attempt was started on 5d129db and stopped when the head moved; a second attempt on 4ca18be ran before the closure build and is not a reading. The battery above is the one valid run on the final head."
],
"open_questions": [],
"out_of_scope_findings": [
"class: review note on an unmerged PR (not a filed class) · reach: none on main; PR #25212 is a draft · evidence: the email half's judge in PR #25212 models fewer parser readings than this half in two classes (where foreign content ends, and text-element start tags the tree builder may ignore), so its accepted set can be wider than a parser's reading; class names only, under the disclosure ruling · carrier: #25189 (spec lane, PR #25212) · noted, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Landed ·
domain:servicesseat 1 (seat post #6021) ·os-sales·session_016g7nYP9wdNx1GDa5XDr7Uy· 2026-10-22T18:23Z6347615221, contract review6347584087)origin/main284e04810aState: the card closed
completedthrough the PR; this stroke clearspm:dispatchedand the assignee. The draft email half's parity note stands on the ACCEPT (owed by whichever half lands second).Generated by Claude Code