Skip to content

fix(plugin-email): the subject and body_text faces render without HTML escaping, so a text-part link keeps its literal & - #20392

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20374-plain-text-unescaped
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20374-plain-text-unescaped

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20374

Clause-②: no

What was wrong

EmailService rendered every face of a sys_email_template row through the HTML escaper (escapeHtml in template-engine.ts), but only body_html is markup. Measured on c577e666 through the real EmailService over the seeded BUILTIN_AUTH_TEMPLATES rows, the text part of the verify, reset, invitation and magic-link mails read …?token=abc&callbackURL=%2F. A plain-text client, or a user copying the link, gets a parameter named amp;callbackURL, and the post-verification redirect falls back to /.

What changes

  • template-engine.ts: one internal render() with a per-face hole encoder.
    • renderTemplate (the HTML face, and the helper the package exports) keeps escapeHtml for {{x}} and verbatim for {{{x}}}, exactly as before.
    • The new renderPlainTextTemplate (the plain-text face) renders every hole verbatim, double or triple braced, formatter output included. It is exported from the module only. The package entry does not re-export it, so the public surface is unchanged: the built dist/index.d.ts names it 0 times, and the built CJS renderTemplate still answers a&b.
  • email-service.ts resolveAndRenderTemplate: this is the ONE render entry behind both sendTemplate and IEmailService.renderTemplate. subject and body_text now go through the plain-text renderer, and body_html through the HTML renderer. The derived fallback is unchanged and was already right: when a row has no body_text, the text is htmlToText(html), which decodes the entities.
  • No template edit. Nothing in templates/auth-templates.ts changes. This follows the triage direction: the engine switch covers every row that reaches the renderer, whether built-in, declared or authored in Studio.

Also fixed in place: the subject (same defect class)

The subject is a plain-text face too (the mail Subject header, sys_email.subject, and the inbox title). On c577e666 it rendered Verify your R&D email address and O'Brien invited you to R&D. It passes all four bounded in-place conditions:

  • it is this card's defect class, a plain-text face run through the HTML escaper;
  • the fix is mechanical, and its shape is already set: the same renderPlainTextTemplate call;
  • it is the same line of the same render entry, which the claim's file surface already names (email-service.ts, where the text body is rendered);
  • it uses the same test family and adds no verification surface.

Evidence: the subject pins below, red under ablation A2.

One file outside the claim's file surface: a doc sentence this change made false

content/docs/automation/email-templates.mdx said that {{path}} is HTML-escaped in "Subject and both bodies". After this change that is false for subject and bodyText. The page now says that escaping follows the face. .claude/agents/os-dev.md requires fixing a published statement that a change makes false, while the claim's surface names only packages/plugins/plugin-email/src/ and the changeset. The report calls out this conflict. No other doc or skill describes template escaping (a git grep for escaping in content/docs and skills).

Measurements behind the dispatch's mechanism assumptions

  1. Call site.
    • template-engine.ts has a single render function; the {{/{{{ switch was at :103 / :120 and escapeHtml at :78.
    • ENTITIES / decodeEntities are used only by htmlToText, the derived-fallback path. The declared body_text path called no decode and had no text mode.
    • Today body_text holds & for a URL carrying an ampersand, < for a value carrying a less-than sign and ' for an apostrophe (measured, all four link templates).
  2. One switch covers every template. git grep finds exactly one non-test caller of the engine's renderTemplate in the repo: resolveAndRenderTemplate.
    • Built-in auth rows, declared emailTemplates and Studio-authored rows all reach it through sendTemplate or IEmailService.renderTemplate.
    • The messaging inbox channel is the one non-email consumer. It reads IEmailService.renderTemplate and stores subject as the title and text as body_md.
    • Every call site is covered; none is left out.
  3. Nothing relies on escaped text.
    • No test expected entities in a text body. The positive control: the grep does find the HTML-face expectations in template-engine.test.ts.
    • template-locale-resolution.test.ts wrapped its subject expectations in an esc() mirror of the escaper. That was vacuous on its Intl date values, which carry no escapable character, but it stated the old belief, so it is rewritten: subjects are now compared unescaped, and html expectations keep esc().
  4. Invitation email. auth.invitation lives in this package (templates/auth-templates.ts, in en-US, zh-CN, ja-JP and es-ES). plugin-auth auth-manager.ts sends it through sendTemplate, so the engine fix covers it with no edit outside the file surface. It is pinned below.

Tests

New src/plain-text-faces.test.ts (36 cases) runs the real EmailService over the real seeded rows, plus one authored row that nobody hand-braced:

  • The card's acceptance, per template and locale. For verify, reset, invitation and magic link in 4 locales, the persisted sys_email.body_text and the delivered text part contain the link with a literal ampersand and no &.
  • The HTML part is unchanged, per template and locale. The href carries the link verbatim, and the visible copy-paste span is still the escaped markup.
  • Control, per face. A value carrying a less-than sign and an ampersand renders escaped in body_html and literally in body_text and the subject. This holds for a built-in template, for an authored template, for the render-only renderTemplate the inbox reads, and for a row with no body_text (the derived fallback, with entities decoded).

src/template-engine.test.ts: 6 new renderPlainTextTemplate cases, including a control that runs the same inputs through renderTemplate and gets them escaped.

Results at eb169bd4 for @objectstack/plugin-email:

  • pnpm test: 31 files and 510 tests passed. It ran before the ablations and again after them, from the committed tree.
  • typecheck: tsc --noEmit exits 0, and check:test-typecheck reports 0 errors.

HTML part byte-identity. One-time proof, not kept as a test. BASE c577e666 renderTemplate and HEAD renderTemplate rendered every built-in bodyHtml (24), plus 2 hole-shape extras, with hostile data (apostrophe, quotes, ampersand, markup). 0 of 26 differ. The positive control shows the comparison can see a change: 24 of 24 bodyText renders differ between BASE HTML mode and HEAD text mode.

Ablation: every negative pin, one-time, not committed

Each leg ran through scripts/ablation-replace.mjs in WRAP mode on the committed fix at eb169bd4. The anchor hit 1 of 1 each time, and the blob changed on disk. The tests import ./email-service.js and ./template-engine.js relatively, so vitest runs src/ and no dist/ is in the resolution path.

leg mutation result
A1 body_text back to renderTemplate in email-service.ts 19 failed / 48 passed
A2 subject back to renderTemplate 3 failed / 64 passed
A3 the plain-text encoder made to escape (template-engine.ts) 23 failed / 44 passed
  • A1 broke every text-part pin, the three control text pins and the render-only pin. For example, for auth.verify_email [en-US] it received …?token=TOK123&callbackURL=…. The 16 HTML-unchanged pins and the fallback pin stayed green, as expected.
  • A2 broke the three subject pins; the received value was O'Brien invited you to R&D <Lab>.
  • A3 broke the 4 engine verbatim cases and every service pin above.

Every restore is proven: the blob equals HEAD (e5a222f83fb1 for email-service.ts, 86d358762d50 for template-engine.ts), git diff HEAD is empty and git status is clean.

Gates (HEAD eb169bd4)

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands was run with no paths: 7 paths against merge base c577e6663, 90 commands. The dispatch-time list had 61 commands; the extra ones are the docs families that the content/docs edit adds. Each command was run with its exit code captured right after a single redirect. The --ran verdict: 90 accounted for: 88 run, 2 NOT MEASURED, 0 UNRUN.

  • check:skill-examples first exited 3, because @objectstack/client-react was unbuilt. After building the @objectstack/client-react... closure it exited 0: 259 examples type-check.
  • NOT MEASURED: check:dual-build-cjs-loads. Exit 3: 51 packages have no dist/, and the gate needs a whole-repo pnpm build. Declared narrowing: the diff touches no package.json, tsup config or exports, so only this package's dist bytes move. On plugin-email's build, CJS require and ESM import both load, each with 93 exports and an identical key set.
  • NOT MEASURED: check:type-check-debt. Exit 3: 16 dependencies of ledgered packages are unbuilt. Declared narrowing:
    • The DEBT ledger has 4 entries: cloud-connection, hono, observability and the workspace root.
    • None of those three packages depends on plugin-email, and the root program is scripts/ plus top-level configs, which this diff does not touch.
    • plugin-email itself is covered: tsc --noEmit exits 0 and its test layer has 0 errors.
  • Lint, narrowed.
    • eslint --no-inline-config --format json on the 5 touched .ts files: 5 files, 0 errors, 0 warnings.
    • The population comes from eslint's own config: each file resolves a config through --print-config (exit 0) and none is reported ignored.
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules, as its own comment at :326-328 states), so this diff cannot move the verdict on any untouched file.
  • origin/main moved 6 commits since the base. None of them touches plugin-email, the doc page, service-messaging or the spec email contract (empty diffstat).

Acceptance notes

  • Inbox consumer. The messaging inbox channel stores rendered.text as body_md, so an authored value carrying markup characters now reaches that markdown field raw instead of entity-encoded. This is not a new exposure class: a row with no body_text already delivered raw text through the htmlToText fallback, and the contract (RenderTemplateResult.text, "rendered plain-text body") already said plain text. How the console renders body_md (raw HTML allowed or not) is NOT MEASURED, because no objectui checkout is in this container. Whoever next touches the inbox renderer owns that question. Owner: none.
  • Related but separate: 邀请注册链路丢失 redirect:signUp 不传 callbackURL,被邀请人验证邮箱后被带去「创建工作区」 objectui#10893. Sign-up passes no callbackURL, which is a different repo and a different mechanism.

Generated by Claude Code

…L escaping

A template's plain-text faces went through the HTML escaper, so the
text/plain part of every auth mail carried `&` in its link and a
plain-text reader lost the `callbackURL` parameter. The engine now has a
plain-text render mode; `EmailService` uses it for `subject` and
`body_text`, and keeps the HTML mode for `body_html` only.

Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-email, touching 4 documentable anchor(s).

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

  • content/docs/automation/email-templates.mdx (via renderTemplate (symbol, a top-level function))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via renderTemplate (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 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 — 5 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 15bf186f509521a1be530d19351e0ea63db4a2af → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 15bf186f509521a1be530d19351e0ea63db4a2af

⚠️ 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 15bf186f509521a1be530d19351e0ea63db4a2af → 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: eb169bd4581954e934785b082f26488b967f18aa
Local-runs: none

① Derived judgments

Inputs read: card #20374 body and all 6 comments; PR #20392 body, its 7-file list and the net diff c577e666..eb169bd4 (+332/−21); the 35 check-runs on the head. The seat's claim, its ACCEPT and the dev report were read as claims and re-derived from the tree.

  • Public surface unchanged — right. packages/plugins/plugin-email/package.json exports addresses only . (dist/index.*); the shared tsup entry is src/index.ts; src/index.ts is not in the diff and still exports renderTemplate, requireVars, htmlToText from the engine and nothing else. renderPlainTextTemplate is reachable from no entry type, so per the contract-review rule it is shipped bytes, not a published accept set. IEmailService, RenderTemplateInput and RenderTemplateResult in packages/spec/src/contracts/email-service.ts are untouched; that contract promises subject as "Rendered subject line" and text as "Rendered plain-text body" and never promised entities, so the change moves the implementation toward the contract.
  • HTML face byte-identical — right, shown statically. The head's private render() body is the base renderTemplate body with one line changed: escapeHtml(str) became encode(str), and renderTemplate passes encode = escapeHtml. Every hole shape (double-brace, triple-brace, formatter) therefore takes the exact base path for body_html; the base isUnescaped test at line 103 and the switch at line 120 are the same expressions at head lines 144 and 161.
  • Plain-text face semantics match the doc page — right. With encode = verbatim, isUnescaped ? str : encode(str) yields str for both brace shapes; a formatted hole is encoded after formatValue, so formatter output is verbatim; a missing value returns the empty string before encoding on both faces. The changeset's "triple braces mean the same as double there" and the page's "missing placeholder renders as an empty string" and "unknown formatter falls back to the raw value" all hold on the code.
  • Behaviour change on published outputs — right, and no in-repo consumer relied on the encoded form. resolveAndRenderTemplate (email-service.ts:1386-1390) is the one render entry behind sendTemplate and IEmailService.renderTemplate; the only non-test caller of the engine's renderTemplate in the repo is line 1387 (inbox-channel.ts:182 calls the service method; expression.zod.ts:469 is an unrelated local function). Consumer sweep: plugin-auth/auth-manager.ts five sendTemplate sites (1682, 1744, 3366, 3620, 5185) read only result.status; service-messaging/email-channel.ts:338 reads only status and id; the three transports pass subject, text, html as separate fields (smtp.ts:219-222, resend.ts:51-54, postmark.ts:51-55) and compose no markup; rowToNormalized (email-service.ts:392-403) forwards row.subject and row.body_text as-is, so the queued and stranded delivery paths send the bytes the row holds. Zero readers of sys_email.body_text or subject outside plugin-email (positive control: the same grep for body_html hits the platform-objects field declarations and the dogfood fixture); zero entity spellings or entity decoders outside plugin-email other than three unrelated escapers (the 404 page host in serve.ts, approvals action pages, escape-mdx). Zero tests outside plugin-email assert entity spellings on subject or text (positive control: plugin-email's own template-engine.test.ts hits on the HTML face and on htmlToText inputs).
  • No plain-text face flows into markup on any path in this repo — right. Subject: send() trims it (email-service.ts:273) and hands it to the transport header and to sys_email.subject (Field.text); no HTML composition anywhere. Inbox: inbox-channel.ts:186-187 stores rendered.subject as title (Field.text) and rendered.text as body_md (Field.markdown, written at line 204); no runtime package renders markdown to HTML (grep for marked, markdown-it, remark, micromark over packages/** non-test: zero). The inline notify path already wrote unencoded interpolated values into the same column (notify-node.ts execute(): stringifyForTemplate(interpolate(cfg.message)), then messaging-service.ts:1100-1101 str(payload.body), then inbox-channel.ts:204), and the no-body_text fallback already decoded entities via htmlToText; so the template path now matches two pre-existing paths and opens no new class here. Console rendering of body_md is outside this repo and stays unmeasured, as the PR body says.
  • Derived fallback unchanged — right. A row with no body_text still takes htmlToText(html) where html is the unchanged HTML face; decodeEntities is used only inside htmlToText. Pinned by the new "row with NO body_text" case.
  • Doc page content/docs/automation/email-templates.mdx — every sentence right against the code. Only the Placeholders intro and the two brace bullets change. "Escaping follows the face: only bodyHtml is markup, so only bodyHtml HTML-escapes its values" is lines 1386-1390. "subject and bodyText … render every value verbatim — an ampersand in a link stays an ampersand, so the plain-text part never needs triple braces" is the verbatim encoder. The double-brace bullet ("HTML-escaped in bodyHtml, verbatim in subject and bodyText") and the triple-brace bullet ("not escaped even in bodyHtml") match renderTemplate and renderPlainTextTemplate. The unchanged sentences stay true ("missing placeholder renders as an empty string", "unknown formatter falls back to the raw value", "bodyText … derived … by stripping tags"). This is the only doc or skill describing template escaping: a grep for escaping and triple braces over content/docs/** and skills/** hits only this page; content/docs/kernel/runtime-services/email-service.mdx says nothing on escaping; the spec .describe() strings for subject, bodyHtml, bodyText (email-template.zod.ts:157-167) say nothing on escaping, so the generated reference is not made false. The page is hand-written (not references/ or releases/), and no meta.json change is needed.
  • Test edits — right. template-locale-resolution.test.ts: expected().when is an Intl short date plus short time, which carries no escapable character, so esc() on the subject was vacuous; dropping it states the new truth and html keeps esc(). plain-text-faces.test.ts: 4 templates × 4 locales × 2 plus 4 controls = 36 cases; every expectation is an independent literal; the harness runs the real EmailService over BUILTIN_AUTH_TEMPLATES and one authored row nobody hand-braced. template-engine.test.ts: 6 new cases including the HTML-face control.
  • PR body and changeset sentences — checked, none false. Verified: the single non-test engine caller; the inbox channel as the one non-email consumer; base line refs :78, :103, :120; ENTITIES and decodeEntities used only by htmlToText; auth.invitation sent via sendTemplate at auth-manager.ts:3366; "No test expected entities in a text body"; main moved 6 commits (c577e666..15bf186f) with an empty diffstat on plugin-email, the doc page, service-messaging and the spec email contract; on the base the subject went through escapeHtml, so the apostrophe entity and ampersand entity claims for subjects hold. The dist byte claims (index.d.ts names it 0 times; the built CJS renderTemplate still escapes) follow from the exports and entry graph and were not re-measured here.
  • Two NOT MEASURED gates — answered green by the head's check-runs. check:dual-build-cjs-loads runs in ci.yml job build-core (line 2116, inside the job spanning 1960-2161): check-run Build Core success. check:type-check-debt runs in lint.yml job typecheck-debt (line 6296): check-run Type Check · debt ledger success; the aggregate TypeScript Type Check success.
  • Check-runs on the head: 35 runs, latest per name all success except Console Pin Gate and Packed-tarball smoke (opt-in), both skipped by design (no pin change, no opt-in). All seven required contexts success. Check Changeset success. No governed surface in the file list; 353 changed lines; head repo equals base repo; draft against main.
  • Commit and PR-bound text: the head commit ends with the model-free trailer pair; no model identifier appears in the commit message, PR body, changeset, doc page or diff.
  • Nit (no effect on the conclusion): the PR body's third in-place condition reads "the same line of the same render entry, which the claim's file surface already names", where os-dev.md ③ reads "no other claim holds the file". The fact ③ names is established anyway: the dispatch read 0 of 15 open PRs touching plugin-email, and the head's check-run "No other open PR may claim the same single-writer path" is success.

② Semver level

  • @objectstack/plugin-email is a released package (no private, files ships dist, README.md and CHANGELOG.md, version 17.4.0); .changeset/20374-plain-text-faces-unescaped.md declares it patch — right: a bug fix in a released package takes patch, never none and never skip-changeset.
  • Clause-②: no — right. Accept set: requireVars, the loader, the locale ladder and every refusal (TEMPLATE_NOT_FOUND, TEMPLATE_INACTIVE, MISSING_VARIABLES) are untouched; nothing formerly refused is accepted and nothing formerly accepted is refused. Public surface: the exports map and src/index.ts are unchanged and renderPlainTextTemplate is unreachable from the entry. No packages/spec/src/** path in the diff, so the path arm is not hit; no narrowing arm applies. The PR body carries Clause-②: no at line 3, matching the claim; Check Changeset is success.
  • Changeset prose: states what was wrong, what changes, who sees it and what is unchanged; every sentence verified in ①; nothing is removed or renamed, so no migration text is owed.

③ Boundary flags

  • Flag: the subject fixed in place (declared deviation). Against os-dev.md's four bounded in-place conditions: ① same defect class — a plain-text face rendered through the HTML escaper, the adjacent line of the same render entry: holds. ② mechanical, shape already pinned — the same renderPlainTextTemplate call, pinned by three subject literals in plain-text-faces.test.ts (built-in control, authored row, render-only): holds. ③ no other claim holds the file — 0 of 15 open PRs touched plugin-email at dispatch and the single-writer-path check-run is success: holds. ④ same gate family, no new verification surface — same vitest family, no new gate: holds. The two debts: the claim's file surface already names email-service.ts, so nothing was owed there, and the PR body names the fix with evidence. Disposition holds.
  • Flag: doc page outside the claimed surface (declared CONFLICT). os-dev.md line 67 requires fixing a published statement the round makes false; the base sentence naming the double-brace hole as HTML-escaped under "Subject and both bodies" became false. The edit is owed, was declared in the report, and the seat's ACCEPT records the revised surface. Every sentence checked in ①. Disposition holds.
  • Flag: out-of-scope note — inbox body_md now receives raw authored values (unfiled, in Acceptance notes). Holds as an acceptance note, not a blocker: the same column already received unencoded values from the inline notify path and from the no-body_text fallback (evidence in ①); RenderTemplateResult.text is contracted as plain text; this repo has no markdown-to-HTML renderer, so the only open question (whether the console renders raw HTML inside body_md) lives in objectui and can be given no repro evidence from here. Prime Directive 10 asks a card to name its evidence; none is measurable on this tree, so leaving it as a note is the right disposition. No measurable regression is introduced on this tree.
  • Flag: two NOT MEASURED gates — answered by Build Core and Type Check · debt ledger on the head (①).
  • Flag: CI not awaited at report time — now fully green on eb169bd4.
  • open_questions: empty — nothing to answer or escalate.

Implemented-by: claude/issue-20374-plain-text-unescaped
Reviewed-by: session_01TEah6PeJGjxJfbHaySJjLQ

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 06:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit df3ba16 Sep 28, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20374-plain-text-unescaped branch September 28, 2026 06:59
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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-email:auth 邮件纯文本部分把链接里的 & 转义成 &amp;,纯文本链接丢失 callbackURL

2 participants