Repository navigation
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
Conversation
… every hole's value for markup Ruling 3C on #25040: a text field is never stripped at the write; the outlet that renders it into HTML escapes it by default. The subject and the markdown/text faces keep rendering values verbatim. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…let list itself, per package Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…of notification templates Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…tlet-context-escaping
… store instead of a new engine double Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 68c170d2256e995a69ad6eb85ef21a8aae451a4d && git checkout 68c170d2256e995a69ad6eb85ef21a8aae451a4d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b1382a5b3887e4abd194411ef77a7cd368c95c2 7aeddce6f3626efe1fefaaa213b52685bea2ff24 && git checkout -B drift-repro 2b1382a5b3887e4abd194411ef77a7cd368c95c2 && git merge --no-ff 7aeddce6f3626efe1fefaaa213b52685bea2ff24
node scripts/docs-audit/affected-docs.mjs --json 2b1382a5b3887e4abd194411ef77a7cd368c95c2 |
…EAKING minor, Clause-② no (narrowing) Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsFace: narrowing —
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对 · ACCEPT ·
|
Fixes #25061
Clause-②: no (narrowing)
Carries out ruling 3C on #25040 (
6308462370): no server-side stripping or refusal of markup in text fields; every outlet that renders a text field into HTML escapes it by context, the mail template engine by default, with a pin on the outlet list. Disclosure: the #9387 ruling (5323310017) applies to this PR. Fixtures are neutral markup (a bold or italic element, an ampersand, quotes), and nothing here describes a request.What changed
@objectstack/service-messaging(the one outlet that did not escape).renderNotificationrendered asys_notification_templatebody of formathtmlormjmlby placing each{{ payload.x }}value into the markup as stored. The email channel sends that string as the mail's html part, so a payload text field carrying markup characters (a display name, a record title, a comment preview) reached the recipient's mail client as markup. Each hole's value in an html or mjml body is now HTML-escaped, in element text and quoted attributes alike; the template's own markup renders as written. The subject and amarkdownortextbody are plain text and stay verbatim. No unescaped hole form is added (none existed, and nothing in the tree needs one); a template that needs a pre-rendered fragment uses an email template, whose renderer has one.src/text-field-html-outlets.pin.test.tsin each package that holds an outlet): one pin on the outlet list itself, and per-entry pins that drive the real render path..changeset/25061-notification-template-html-escape.md,@objectstack/service-messagingminor, BREAKING (patch round 1; it was a patch before the seat ruled question 1 B).Nothing was edited in
packages/spec,packages/objectqlorpackages/platform-objects.Outlet census
The census is the AST walk each pin file carries: a shipped source file "touches HTML" when an identifier or a string in its CODE names
html, or a string carries an HTML element tag (comments never count). I ran the same walk over everydomain:servicespackage (packages/services/*/src,packages/connectors/*/src,packages/triggers/*/src,packages/plugins/*/src) atc10cc2a71b, seeded bygit grepfor escaper helpers,text/html,body_htmland HTML tags next to interpolation. Every hit is classified below.template-engine.tsrenderTemplateis the HTML face,renderPlainTextTemplatethe plain-text facesbody_htmland verbatim in the subject andbody_text; the triple-braced hole is the one unescaped formemail-service.tsresolveAndRenderTemplate, behindsendTemplateandrenderTemplatebody_htmlgoes through the HTML facetemplates/auth-templates.tsindex.ts,template-loader.ts,transports/smtp.ts,transports/resend.ts,transports/postmark.tstemplate-renderer.tsrenderNotificationfor an html or mjml bodyemail-channel.tsobjects/notification-template.object.tsformatvocabularyaction-link-pages.tsapproval-service.ts,approvals-plugin.tstext/htmlingest/mail-ingest-service.ts,ingest/parse-message.ts,objects/sys-mail-message.object.tspackages/rest/src/export-format.ts). The print service renders the console's own record page in a headless browser, so the UI renders the value there; the server puts no field value into markup.connector-slackposts caller-authored text verbatim, in Slack's markup, not HTML.service-smssends plain text. The messaging digest renders plain text.packages/cli/src/commands/serve.ts(host-not-found page) escapes its one value.packages/plugins/plugin-hono-server/src/adapter.tscarries an already-rendered page. One further out-of-lane HTML builder, outside ruling 3C's text-field scope, is named to the seat privately. The UI renderer lives in objectui.The engine's raw form, measured
plugin-email's engine has exactly one unescaped form, the triple-braced hole.service-messaging's renderer had none (every hole was unescaped), and it still has none.type: 'url'variable inside anhrefattribute, for a link the platform minted. The only other uses are plugin-email's own tests and the authoring doc (content/docs/automation/email-templates.mdx).examples/app-showcase's authored template uses double braces only.hrefattribute. Every text variable must render escaped through the realEmailService.sys_user.namemaxLength: readingThe declaration, at base
4bf079c693, isField.text,required,maxLength: 255(packages/platform-objects/src/identity/sys-user.object.ts:776-781). I measured it on a real ObjectQL with SqlDriver (in-memory sqlite) under the system context, which is the context the auth adapter writessys_userwith (asAttributedSystemin plugin-auth'sobjectql-adapter.ts). I used a scratch test that is not committed.VALIDATION_FAILED, field errormax_length, constraint maxLength 255 / actual 256VALIDATION_FAILED; the stored value is unchangedReading: enforced at the write seam, by the objectql write validator's bounded-string branch, which is #11875's rule (
textis inBOUNDED_STRING_FIELD_TYPES). No edit is owed inplatform-objectsorobjectql.Pins and ablation
There is one leg per pinned behaviour. Each mutation went through
scripts/ablation-replace.mjsin wrap mode (the anchor must hit once; landing is proven by on-disk counts and a blob change). Each restore isgit checkout HEAD -- PATH, proven by restored blob equal to the HEAD blob and an emptygit diff HEAD. No mutation needs a rebuild: every pin imports its subject fromsrc.renderTemplateencodes verbatimLegs B, E and G first ran as no-ops: each replacement contained its own anchor, so
ablation-replacerefused the leg as not landed and restored before any test ran. They were re-run with replacements that do not contain the anchor (B2, E2, G2), and the readings above are from those re-runs. All legs ran atc10cc2a71b, and every restore was proven.Tests and gates (final head
1960b459bb)Every run went under
scripts/pm/os-verify-lock.sh, after the closure build (pnpm --filter 'PKG^...' --filter PKG buildfor the three packages; exit 0).pnpm --filter @objectstack/service-messaging test1960b459bbpnpm --filter @objectstack/service-messaging typecheck1960b459bb--listFilescount 1)pnpm --filter @objectstack/plugin-email test73f7bf8fe5pnpm --filter @objectstack/plugin-email typecheck73f7bf8fe5check:test-typecheck: OK, with 0 errors held and the new file in the test program (count 1)pnpm --filter @objectstack/plugin-approvals test73f7bf8fe5pnpm --filter @objectstack/plugin-approvals typecheck73f7bf8fe5check:test-typecheck: OK(ledger unchanged, the new file is in the test program, count 1)The only commit after
73f7bf8fe5is1960b459bb, which edits the service-messaging pin file alone. Its package re-ran on that head, and so did the ablation legs that read the file (A and B, again 3/8 and 1/8 red).Derived gate families.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 68 commands. All 68 ran, each with its exit captured before any pipe, and--ranreconciles 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every line records an exit code).73f7bf8fe5, 65 exited 0.check:engine-double-contractwent red there, because the first draft of the channel-path pin added a new engine double. The pin now hands the channel a stored-row store instead, and the gate is exit 0 at1960b459bb.check:i18nandcheck:dual-build-cjs-loadsexited 3 (prerequisite not met: no full build). After a full build, both are exit 0 at1960b459bb.1960b459bb, all exit 0:check:engine-double-contract,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage,check:type-check-debt,check:objectql-double-limitandcheck:nul-bytes.The repo-wide scans and the CI-only lanes (Test Core shards, Build Core, the type-check lanes,
test:repo) are CI's. I did not re-run them locally.Dogfood battery:
n/a. The title has noSuperseded by patch round 1: the claim moved to!and the claim has no(narrowing)arm (see open question 1).(narrowing), and the full battery ran on head7aeddce6f3(see below).Acceptance notes
service-messaging's html face has no unescaped hole by design. The migration line in the changeset names the email-template route for a pre-rendered fragment.typescriptparser, which is already a devDependency of all three. No gate, script orturbo.jsonentry was added.Open questions for the seat
Clause-②: does the html-face escaping count as a narrowing? It changes what an existing html or mjml
sys_notification_templaterow renders when a hole's value carries an ampersand, an angle bracket or a quote. That includes a row that relied on a payload value rendering as markup. Measured reach in this tree at73f7bf8fe5:fixture-18.0andfixture-18.0.0, and noformat: 'html'or'mjml'outsidepackages/spec);notify, the audit mention writer, the automation notify node);The PR is declared
Clause-②: noas claimed. If the seat amends the claim tono (narrowing), this PR owes a!title, a BREAKING changeset with its ADR-0087 disposition, and the full dogfood battery on the final head.The list pin's radius. Options:
scripts/cross-package-test-inputs.mjsandturbo.json, outside this claim's file surface;repovitest project.One out-of-lane HTML builder (outside ruling 3C's text-field scope) is named to the seat privately, for the seat to declare cross-lane. Its reach is not measured.
Patch round 1
fix(service-messaging)!,Clause-②: no (narrowing), ADR-0087not-required (no-migration-prescription)), head7aeddce6f3.7aeddce6f3: VERDICT command-exit 0, Test Files 286 passed / 3 skipped (289), Tests 2387 passed / 18 skipped (2405), 0 failed.Generated by Claude Code