Skip to content

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

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-25061-outlet-context-escaping
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-25061-outlet-context-escaping

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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). renderNotification rendered a sys_notification_template body of format html or mjml by 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 a markdown or text body 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.
  • Pins, in three NEW files named by the rule (src/text-field-html-outlets.pin.test.ts in each package that holds an outlet): one pin on the outlet list itself, and per-entry pins that drive the real render path.
  • Changeset: .changeset/25061-notification-template-html-escape.md, @objectstack/service-messaging minor, BREAKING (patch round 1; it was a patch before the seat ruled question 1 B).

Nothing was edited in packages/spec, packages/objectql or packages/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 every domain:services package (packages/services/*/src, packages/connectors/*/src, packages/triggers/*/src, packages/plugins/*/src) at c10cc2a71b, seeded by git grep for escaper helpers, text/html, body_html and HTML tags next to interpolation. Every hit is classified below.

Package File Role Escapes by context?
plugin-email template-engine.ts outlet: the engine. renderTemplate is the HTML face, renderPlainTextTemplate the plain-text faces yes, by default, unchanged: a double-braced hole is HTML-escaped in body_html and verbatim in the subject and body_text; the triple-braced hole is the one unescaped form
plugin-email email-service.ts outlet: resolveAndRenderTemplate, behind sendTemplate and renderTemplate yes: body_html goes through the HTML face
plugin-email templates/auth-templates.ts outlet: the built-in auth templates yes: every text variable is double-braced; see the raw-form reading below
plugin-email index.ts, template-loader.ts, transports/smtp.ts, transports/resend.ts, transports/postmark.ts not an outlet: re-export, column mapping, and handing the rendered html part to the provider n/a
service-messaging template-renderer.ts outlet: renderNotification for an html or mjml body was no; fixed here
service-messaging email-channel.ts not an outlet: forwards the rendered html; its template arm delegates to plugin-email's engine n/a
service-messaging objects/notification-template.object.ts not an outlet: declares the format vocabulary n/a
plugin-approvals action-link-pages.ts outlet: the session-less confirm and result pages an approval link opens yes, unchanged: every request text field goes through its escaper
plugin-approvals approval-service.ts, approvals-plugin.ts not an outlet: wrap or serve that rendered page as text/html n/a
service-mailbox ingest/mail-ingest-service.ts, ingest/parse-message.ts, objects/sys-mail-message.object.ts not an outlet: stores the html part of INBOUND mail; renders nothing n/a
  • HTML exports: none on the server. Record exports are csv, xlsx and json (packages/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.
  • Integration payloads: no builder emits HTML. Webhooks serialize JSON. connector-slack posts caller-authored text verbatim, in Slack's markup, not HTML. service-sms sends plain text. The messaging digest renders plain text.
  • Other lanes (named for the seat, not touched): packages/cli/src/commands/serve.ts (host-not-found page) escapes its one value. packages/plugins/plugin-hono-server/src/adapter.ts carries 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

  • What exists. 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.
  • Who uses it. The built-in auth templates have 20 sites: password reset, verify email, magic link, invitation accept and account-invitation set-password, times four locales. Each one names a type: 'url' variable inside an href attribute, 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.
  • Kept, and pinned. The built-ins keep that raw form. The new pin holds them to it: in every built-in body, each unescaped hole must name a declared url variable and sit inside an href attribute. Every text variable must render escaped through the real EmailService.

sys_user.name maxLength: reading

The declaration, at base 4bf079c693, is Field.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 writes sys_user with (asAttributedSystem in plugin-auth's objectql-adapter.ts). I used a scratch test that is not committed.

write answer
insert, name of 255 characters (control) accepted, 255 stored
insert, name of 256 characters refused VALIDATION_FAILED, field error max_length, constraint maxLength 255 / actual 256
update an existing row's name to 256 characters refused VALIDATION_FAILED; the stored value is unchanged

Reading: enforced at the write seam, by the objectql write validator's bounded-string branch, which is #11875's rule (text is in BOUNDED_STRING_FIELD_TYPES). No edit is owed in platform-objects or objectql.

Pins and ablation

There is one leg per pinned behaviour. Each mutation went through scripts/ablation-replace.mjs in wrap mode (the anchor must hit once; landing is proven by on-disk counts and a blob change). Each restore is git checkout HEAD -- PATH, proven by restored blob equal to the HEAD blob and an empty git diff HEAD. No mutation needs a rebuild: every pin imports its subject from src.

leg mutation (on disk) reading on the pin file
A service-messaging html face encodes verbatim red, 3 failed of 8: html, mjml, email-channel path. The subject and plain-text pins stay green, as they should
B an unlisted service-messaging file gains an html-named export red, 1 failed of 8: the outlet-list pin
C plugin-email renderTemplate encodes verbatim red, 32 failed of 66: engine, email-service and every built-in escape pin
D the en-US default footer's app-name hole becomes triple-braced red, 10 failed of 66: raw-hole placement and escape, for the five en-US templates on that footer
E an unlisted plugin-email file gains an html-named export red, 1 failed of 66: the outlet-list pin
F approval summary rows stop escaping red, 2 failed of 5: confirm and result pages
G an unlisted plugin-approvals file gains an html-named export red, 1 failed of 5: the outlet-list pin

Legs B, E and G first ran as no-ops: each replacement contained its own anchor, so ablation-replace refused 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 at c10cc2a71b, 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 build for the three packages; exit 0).

command head result
pnpm --filter @objectstack/service-messaging test 1960b459bb exit 0, Test Files 55/55, Tests 610/610
pnpm --filter @objectstack/service-messaging typecheck 1960b459bb exit 0; the tsc program lists the new pin file (--listFiles count 1)
pnpm --filter @objectstack/plugin-email test 73f7bf8fe5 exit 0, Test Files 36/36, Tests 643/643
pnpm --filter @objectstack/plugin-email typecheck 73f7bf8fe5 exit 0; check:test-typecheck: OK, with 0 errors held and the new file in the test program (count 1)
pnpm --filter @objectstack/plugin-approvals test 73f7bf8fe5 exit 0, Test Files 94/94, Tests 1213/1213
pnpm --filter @objectstack/plugin-approvals typecheck 73f7bf8fe5 exit 0; check:test-typecheck: OK (ledger unchanged, the new file is in the test program, count 1)

The only commit after 73f7bf8fe5 is 1960b459bb, 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/objectstack derived 68 commands. All 68 ran, each with its exit captured before any pipe, and --ran reconciles 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every line records an exit code).

  • On the first pass at 73f7bf8fe5, 65 exited 0.
  • check:engine-double-contract went 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 at 1960b459bb.
  • check:i18n and check:dual-build-cjs-loads exited 3 (prerequisite not met: no full build). After a full build, both are exit 0 at 1960b459bb.
  • The ratchet families were re-run at 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-limit and check: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 no ! and the claim has no (narrowing) arm (see open question 1). Superseded by patch round 1: the claim moved to (narrowing), and the full battery ran on head 7aeddce6f3 (see below).

Acceptance notes

  • The list pin's radius is per package. Each package that holds an outlet today carries its own census, and the census reads only that package's source, so no cross-package input declaration is needed. A new outlet in a package with no list is not seen. The alternatives are in the open questions.
  • 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.
  • These new pin files each scan their own package with the typescript parser, which is already a devDependency of all three. No gate, script or turbo.json entry was added.

Open questions for the seat

  1. Clause-②: does the html-face escaping count as a narrowing? It changes what an existing html or mjml sys_notification_template row 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 at 73f7bf8fe5:

    • authored html or mjml notification-template rows: 0 (no seed, no example, 0 rows in the dogfood upgrade fixtures fixture-18.0 and fixture-18.0.0, and no format: 'html' or 'mjml' outside packages/spec);
    • producers that put pre-rendered HTML into a payload: 0 of the 3 messaging producers (approvals notify, the audit mention writer, the automation notify node);
    • rows stored in deployments: not measurable from here.

    The PR is declared Clause-②: no as claimed. If the seat amends the claim to no (narrowing), this PR owes a ! title, a BREAKING changeset with its ADR-0087 disposition, and the full dogfood battery on the final head.

  2. The list pin's radius. Options:

    • (A) per package, as landed here;
    • (B) one lane-wide census test, which needs a cross-package input declaration in scripts/cross-package-test-inputs.mjs and turbo.json, outside this claim's file surface;
    • (C) a repo-level census in another lane's repo vitest project.
  3. 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

  • Patch round 1: the changeset now ships as a BREAKING minor (fix(service-messaging)!, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription)), head 7aeddce6f3.
  • Full dogfood battery on head 7aeddce6f3: VERDICT command-exit 0, Test Files 286 passed / 3 skipped (289), Tests 2387 passed / 18 skipped (2405), 0 failed.

Generated by Claude Code

… 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>
… store instead of a new engine double

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

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 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
  • 3 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 2b1382a5b3887e4abd194411ef77a7cd368c95c2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 68c170d2256e995a69ad6eb85ef21a8aae451a4d — the merge of head 7aeddce6f3626efe1fefaaa213b52685bea2ff24 into base 2b1382a5b3887e4abd194411ef77a7cd368c95c2, 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 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…EAKING minor, Clause-② no (narrowing)

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7aeddce6f3626efe1fefaaa213b52685bea2ff24
Local-runs: none
Scope: .changeset/25061-notification-template-html-escape.md, packages/plugins/plugin-approvals/src/text-field-html-outlets.pin.test.ts, packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts, packages/services/service-messaging/src/template-renderer.ts, packages/services/service-messaging/src/text-field-html-outlets.pin.test.ts + packages/services/service-messaging/src/email-channel.ts (the sender of the rendered html part), packages/services/service-messaging/src/objects/notification-template.object.ts (the format vocabulary), packages/plugins/plugin-email/src/template-engine.ts (renderTemplate / renderPlainTextTemplate), packages/plugins/plugin-email/src/email-service.ts (resolveAndRenderTemplate), packages/plugins/plugin-email/src/templates/auth-templates.ts (the built-ins), packages/plugins/plugin-email/src/template-loader.ts, packages/plugins/plugin-approvals/src/action-link-pages.ts (esc), packages/objectql/src/validation/record-validator.ts (the bounded-string branch, for the sys_user.name reading), scripts/check-adr-0087-registration.mjs, scripts/pm/check-expected-skips.mjs

① Derived judgments

Face: narrowing — Clause-②: no (narrowing) on the PR body's second line and in the changeset; no schema and no governed path in the diff (Governed Surface Queue Guard: success). Net diff against main (merge base 688052e147): +839/−2 over five files — one shipped source moves (template-renderer.ts, +44/−2), three NEW pin files, one changeset. ⛔ Disclosure (#9387, 5323310017): this record names code locations and reach, never a sample.

  1. Ruling 3C's first half — nothing stripped or refused: right. The only behavioural edit is a HoleEncoder on the renderer's substitution pass (template-renderer.ts :43–59, :75–79). The html-face encoder is a five-character entity mapping, bijective on the value: every character survives, none is dropped, and no path returns a refusal. No write seam, validator, object definition or store is touched (packages/spec, packages/objectql, packages/platform-objects: untouched, as the claim requires). plugin-email's pre-existing htmlToText / stripTags (template-engine.ts :214, :235) strips tags from a TEMPLATE body to derive a text part; it is not in the diff and reads no stored text field.
  2. The escaper covers the html contexts the claim names: right. Ampersand, both angle brackets, the double quote and the single quote are mapped (:53–59) — the full set for element text and for an attribute value in either quote kind. It is the same mapping plugin-email's engine already uses (template-engine.ts :89–95), so the two outlets now agree.
  3. Every hole form in an html or mjml body is escaped, none left raw, none added: right. renderNotification has one hole grammar (TOKEN, :40 — a double-braced dotted path; the top-level conveniences title, body, topic and the spread payload keys resolve through the same lookup) and one body substitution (:128), which takes escapeHtml exactly when format is html or mjml (:127) and verbatim otherwise. No triple-brace or other raw arm exists in the grammar, before or after. The format vocabulary is the four-member select in notification-template.object.ts :159, so the two-way split is exhaustive.
  4. Subject, markdown and text bodies, and markup-free values are byte-identical to before: right. interpolate is now substitute(…, verbatim) with verbatim the identity (:45, :71–73); substitute's body is the former interpolate body with encode(String(v)) in place of String(v) (:75–79), so the plain-text faces render the same bytes. The subject still goes through interpolate (:126). A value carrying none of the five characters passes escapeHtml unchanged, so an html or mjml body with markup-free values renders the same bytes too. The no-template fallback (:132–135) is untouched.
  5. The sender: right. email-channel.ts :378–391 hands rendered.subject and rendered.html to the email service unchanged, so the mail's html part IS the renderer's html face. The channel's other arm (a notify step's template, :345) delegates to plugin-email's sendTemplate, whose html face escapes by default (email-service.ts :1453–1457: renderPlainTextTemplate for subject and body_text, renderTemplate for body_html).
  6. The outlet census: right, with its radius stated. Each pin file's list test asserts touching EQUALS listed, so a new shipped source that touches HTML — an identifier or string naming html, or a string carrying one of the enumerated element tags — turns the test red until classified, and a stale entry turns it red too; the control case proves comments never count. Ablation legs B, E and G in the PR body read 1/N red per package. The census's sight is stated in each file's LIMIT paragraph and holds: it reads this package only, by those two spellings, and proves nothing about a listed outlet's escaping (that is the per-entry pins' job). The per-package radius is the seat's ruling A (③). translations/*.generated.ts is excluded in service-messaging and plugin-approvals with a stated reason (a projection of the object declarations); plugin-email's walker excludes nothing beyond tests and test kits.
  7. The per-entry pins drive the real render path: right. service-messaging: renderNotification directly for html, mjml, subject, markdown and text; and createEmailChannel → the real store class subclassed to hand back one stored row → renderNotification → the structural EmailSenderSurface sink captured (the sink is the one double, and check:engine-double-contract is green on the head). plugin-email: the real renderTemplate / renderPlainTextTemplate; the real EmailService with a capture transport and a row loader; the built-ins pin iterates the real BUILTIN_AUTH_TEMPLATES bodies. One note, not a ground: rowOf re-maps a definition onto an EmailTemplateRow by hand (parallel to template-loader.ts :84) rather than through createRegistryEmailTemplateLoader; the bodies and the engine under test are real. plugin-approvals: the real renderConfirmPage / renderResultPage over esc (action-link-pages.ts :18).
  8. The built-ins' raw holes: right. auth-templates.ts carries exactly 20 triple-braced holes in template bodies (a 21st textual match is the file's doc comment, which the pin never reads: it reads bodyHtml), every one inside a double-quoted href and naming a type: 'url' variable — the pin's two conditions. Ablation leg D read 10/66 red.
  9. The PR body's outlet table and the sys_user.name reading: consistent with the tree. The census classifications match the code read. The sys_user.name reading cites the write validator's bounded-string branch, which exists (record-validator.ts :1082–1096, BOUNDED_STRING_FIELD_TYPES); the measurement itself was the dev's scratch run and is not re-run here (read-only).
  10. Check-runs on the head, read at 2026-10-21T07:53Z: every gate-carrying run is success — Lint & Repo Gates (all check:*, the ADR-0087 and changeset gates included), every Lint and Type Check lane, Build Core, Test Core 1/6–6/6, Dogfood Regression Gate (aggregate), Check Changeset (re-run on the edited body), Governed Surface Queue Guard, the three claim guards. Every skip is on check-expected-skips.mjs's roster: the full-battery-only jobs (the Dogfood matrix placeholder, Dogfood Verify CLI, Console Pin Gate, Temporal Conformance), Build Docs (filter), the two opt-ins, and the edited-event runs of Auto Label and Check PR Size beside their concluded opened runs.

② Semver level

  • Level: right. @objectstack/service-messaging: minor, declared BREAKING, under the launch-window convention; plugin-email and plugin-approvals move test files only and publish nothing, so they owe no changeset. Check Changeset: success.
  • Clause-②: no (narrowing) as a BREAKING minor: right. The dispatch (6309505551) set the trigger — if the default escaping changes what an existing authored template renders, the declaration moves to the narrowing arm — and it does: an html or mjml row whose author relied on a value rendering as markup renders differently. The claim amendment (6310385063) moved the arm; the PR body (line 2), the changeset and the bang title agree. Under the amended 「甲」 (6303865025, rule 2) a narrowing no conversion carries ships now in 18.x as a BREAKING minor in the packages: POST /packages/:id/adopt-orphans skips requireWritablePackage, so it can bind loose items into a read-only package #24813 changeset shape, and this changeset is that shape line for line (4cef6e2280): front matter, bang headline, Clause-② line, one adr-0087 marker, BREAKING banner, FROM / TO / Unchanged / remedy. One reading for the seat: what narrows is the rendered-output contract of one face (nothing accepted is refused), so the arm is the conservative one; it is not malformed, and it is the arm the dispatch's own trigger names.
  • ADR-0087 not-required (no-migration-prescription): right. No spec key, authorable spelling, object definition or stored shape moves; no export is removed or narrowed; no stored row is rewritten; the "What to do" bullet is an author's choice of where markup lives, not a rewrite of consumer code — the same shape the gate accepted on packages: POST /packages/:id/adopt-orphans skips requireWritablePackage, so it can bind loose items into a read-only package #24813's "fix for an integrator". The other five categories are closed on facts, as the marker says. The gate is green on the head (Lint & Repo Gates).
  • The changeset's claims against the diff: no over-claim. The headline ("escapes every hole's value for markup") is true of every hole the grammar has, in the two formats the vocabulary makes html-ish. The body bounds the contexts to element text and quoted attributes, and that bound is exactly what the escaper covers (①.2). FROM and TO are the before/after of :128. Each Unchanged bullet is read true in ①.4–5. One further shape in hole placement is held out under the disclosure ruling; escalated privately to the seat. It is outside the changeset's stated bound and outside what ruling 3C asks of the engine, so it is not a ground.

③ Boundary flags

  • Q1 (narrowing?): answered on the record. Ruled B by the seat's claim amendment 6310385063; carried out in patch round 1 (changeset, bang title, body line 2, and the full dogfood battery on this head: 286 files / 2387 tests, 0 failed, per the PR body's appendix — the PR run itself carries only the fast set, so the Dogfood matrix skip is the expected one).
  • Q2 (list-pin radius): answered as A, the as-landed shape. The seat's ruling reaches the record only through the dev's round-1 report (6310641048, "question 2 = A"); the card carries no seat-authored line for it. Nothing in the diff turns on it — escalated for the seat to record the ruling on the card in its own voice.
  • Q3 (one out-of-lane HTML builder): not answered on this record. The brief states the seat filed a separate card; neither the card nor the PR carries a pointer to it. Escalated for the seat to record the pointer (number only, under the disclosure ruling). Not this PR's scope.
  • The dev's pr_body_edit_owed: resolved. Body line 2 now reads Clause-②: no (narrowing) and Check Changeset re-ran green on the edit.
  • Stale sentence in the PR body: the "Tests and gates" section still says the dogfood battery is n/a with no bang title; the patch-round-1 appendix supersedes it. Prose only; the seat may strike it.
  • Dev's out-of-scope notes: one further output context outside HTML, held privately by the seat — carrier the seat; nothing owed here.
  • Hold-offs respected: packages/spec/**, packages/objectql/**, packages/platform-objects/** untouched; no server-side stripping or refusal anywhere in the diff.

Implemented-by: claude/issue-25061-outlet-context-escaping
Reviewed-by: session_011TdZ8GjixNZFLz1xJD9zzK

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-21T07:59Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main c67f244f5a, not against the reports (os-dev reports 6310326246 and 6310641048 on #25061).

Disclosure read first (#9387, 5323310017).

Head under review:

7aeddce6f3626efe1fefaaa213b52685bea2ff24
  1. Shape.
    • Draft, base main, first line Fixes #25061. Title fix(service-messaging)!:, and line-start Clause-②: no (narrowing) in the body and the changeset.
    • 5 files, none on a governed surface (governedPathsIn answers []). The trial merge onto origin/main is clean.
  2. Review of record. The face is narrowing at CONTRACT_REVIEW_TIER, from the claim amendment 6310385063. The isolated contract review 6310965376 reads PASS on this same head (Served-tier: CONTRACT_REVIEW_TIER, Local-runs: none).
    • Since that review, the seat edited only the PR body: one stale sentence is struck. That is a prose edit and moves no head.
  3. The fix, read in the code (template-renderer.ts, +44/−2).
    • What it adds: a hole encoder on the one substitution pass. renderNotification takes the HTML escape exactly when format is html or mjml, and the identity otherwise. The escape maps the ampersand, both angle brackets and both quote kinds.
    • Unchanged: interpolate is the same pass with the identity, so the subject and a markdown or text body render the same bytes as before. So does a value that carries none of the five characters. The no-template fallback is untouched.
    • Ruling 3C (6308462370): nothing is stripped or refused. Every character survives as an entity. packages/spec, packages/objectql and packages/platform-objects are untouched.
    • No raw form: the renderer has one hole grammar and no unescaped arm, before or after.
  4. Pins (three new text-field-html-outlets.pin.test.ts files, read by the seat at TIER_DEFAULT).
    • The list pin: each package's AST census must EQUAL its classified list, so a new HTML-touching source file, or a stale entry, turns it red. A control proves comments never count. The radius is per package, by the seat's ruling A on question 2.
    • The per-entry pins drive the real render paths:
      • renderNotification, and the email channel over a stored row;
      • plugin-email's engine, EmailService and the built-in auth templates, whose raw holes are held to the two conditions the PR body states.
      • plugin-approvals' confirm and result pages.
  5. Gates.
  6. Classification. This brings the one outlet that did not escape to ruling 3C's "each outlet escapes by context". It ships as a BREAKING minor under the amended 「甲」 (6303865025), in the packages: POST /packages/:id/adopt-orphans skips requireWritablePackage, so it can bind loose items into a read-only package #24813 changeset shape, with ADR-0087 not-required (no-migration-prescription).
  7. Accepted deviations.
    • main was merged once, and patch round 1 needed no merge.
    • The round-1 commit subject carries chore(changeset)!:. The squash subject is the PR title.
    • The trailers are model-free.
  8. Open questions and out-of-scope disposition (all abstract, under the disclosure rule).
  9. Landing clause. Not governed, under the human-merge threshold, a BREAKING minor under the launch-window convention. The next step is ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.

Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants