Skip to content

fix(lint)!: refuse an email template HTML body hole the mail renderer cannot encode for its placement (#25189) - #25212

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-s4ghzp
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-s4ghzp

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

Fixes #25189
Part of #25082 (the email half; the parent stays open for the notification half, which is not in this PR)

Clause-②: no (narrowing)
Metadata-protocol: none

Session: session_01Fg7vNwYgyAmHMTJm8rAYsD, the card thread of domain:spec seat 2 (seat session session_01B2EeooonSXrANpkpgUSfFZ). Ruling 6335348115 on #25082, letter F1 A2, items 1, 3 and 4.

What changes

A new gating authoring rule, email-template-hole-placement (validateEmailTemplateHolePlacements), reads every email template's bodyHtml and refuses each hole that sits where the mail renderer's encoding does not suit it. It is one CLI_AND_RUNTIME entry in AUTHORING_RULES with runtimeTypes: ['email_template'], so it runs on os validate, os build, os lint and the runtime publish gate.

  • Supported placements: a double-braced hole in element text (the title and textarea elements included) or in a quoted attribute value that is read as plain text; and a triple-braced hole only when it reads a variable the template declares with type: 'url' and forms the whole value of a double-quoted href.
  • Refused, by class: attributes read as a URL, event-handler attributes, style attributes, attributes read as markup, unquoted attribute values, holes inside a tag, script- and style-like elements, comments, and SVG or MathML content. The classification is context-strict: the allow-list is read off the renderer's own encoder, and anything it does not cover is refused. Where the scan does not model a tokenizer state (a script element's escape states), it fails closed and keeps the hole in raw text.
  • Where it answers: an error finding on the CLI at emailTemplates[i].bodyHtml, and the existing 422 INVALID_METADATA at the publish gate with the same issue. The message names the hole, its offset and its placement; the hint names the supported placements. No new error code.
  • What is not judged: subject and bodyText (plain-text faces), a draft save, and every stored template. A stored template keeps rendering and is judged at its next publish. No migration.

The classifier is shared. judgeMailHtmlHoles lives in @objectstack/formula, because both readers already depend on that package and on nothing else they share: the lint rule, and @objectstack/plugin-email's built-in template pin. That pin now reads the same classifier, and a parity test holds the classifier's hole grammar to the renderer's (an empty render and a markup-carrying render of every built-in body and a grammar corpus, over 200 holes). The renderer itself is not changed.

Measured before crossing

  • HotCRM at c8a7a7047feb: 76 HTML bodies, 116 holes, 0 refused (re-measured with the classifier at 86330c090).
  • The templates this repository ships: 0 refused (the plugin-email pin asserts it per built-in).
  • The five bodies the rule did refuse were non-production and each now uses the supported link shape: two docs examples, one dogfood fixture, and two spec conversion fixtures.

File surface and domain declarations

  • packages/lint: the rule, its registry entry, its barrel export and its tests, and the header comments of runtime-gate.inert-type-writes.test.ts (domain:spec, by the lane table's anchoring exception).
  • packages/formula: the shared classifier, its export and its tests, declared to domain:engine.
  • packages/plugins/plugin-email: the built-in pin, test only.
  • packages/metadata-protocol: one new test file, the host half of the publish door (envelope code and status, a draft not judged). Test only; declared here as part of the rule's tests.
  • content/docs/automation/email-templates.mdx (the placement rule, its refused list including SVG and MathML content, and the two examples), declared to domain:devx. The four-doors table in deployment/validating-metadata.mdx gains the rule's row and the email_template superscript, also domain:devx. Four CLI transcripts (deployment/cli.mdx, deployment/validating-metadata.mdx, getting-started/build-with-claude-code.mdx, ui/react-pages.mdx) print the author-time rule count, which this entry moves from 50 to 51; check:docs-transcript-drift found them, and they are declared to domain:devx too.
  • packages/qa/dogfood fixture, declared to domain:cli.
  • packages/spec/src/conversions/registry.ts: one conversion's example templates only.
  • .changeset/25189-email-template-hole-placement.md: patch for lint, formula and spec, with the BREAKING banner and the ADR-0087 disposition (not-required, no migration prescription).

Tests

At head 86330c090 (formula, plugin-email and the door test re-run after the scan correction; the full lint suite and spec conversions were run on f3bc62099, and since then lint changed only a test file's comments):

  • @objectstack/formula: the classifier, class by class (46 tests); the whole package, 1413 passed.
  • @objectstack/lint: the rule, its registry entry, every command, and the runtime gate (refused and clean writes, and the written template judged alone); with authoring-rule-wiring and runtime-gate.inert-type-writes, 82 passed on 86330c090; the whole package, 7101 passed on f3bc62099.
  • @objectstack/metadata-protocol: the door test, 3 passed. The refusal asserts code: 'INVALID_METADATA' and status: 422 together (ADR-0112), plus the issue's rule and path.
  • @objectstack/plugin-email: the pin, 77 passed; the whole package, 671 passed.
  • @objectstack/spec: src/conversions, 739 passed.
  • typecheck for formula, lint, metadata-protocol, plugin-email and spec: exit 0 each. @objectstack/dogfood typecheck: exit 0 (under the lock, with a raised heap; the default heap ran out of memory once).

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on this diff, then every command run on head 86330c090 with its exit code recorded before any pipe:

  • dispatch-gates --ran: 113 derived families accounted for, 113 run, 0 NOT-MEASURED (a derived zero: every one recorded an exit code and none is 3), 0 UNRUN. All 113 exited 0.
  • An earlier run on 06f68ec4e found check:docs-transcript-drift red on the four CLI transcripts named above (rule count 50, derived 51). They are corrected in f3bc62099. The union was also all green on f3bc62099, and the run above is on the current head.
  • Also run, outside the derived set: check-changeset-fixed, check:meta-url-spelling, check:authz-resolver, check:error-code-casing, check:filter-alias-parity (rosters in a touched directory), check-nul-bytes, check-platform-checklist --changed-from origin/main and check:stack-collection-maps: exit 0 each.
  • The families CI runs (Test Core, Build Core, Build Docs, the type-check lanes and the rest) are CI's and are cited from this PR's check-runs, not re-run here.

Dogfood battery

On head 86330c090, after rebuilding the dogfood closure, pnpm --filter @objectstack/dogfood test under the shared lock: VERDICT command-exit 0 (lock held 1585s). Test Files 287 passed, 3 skipped (290). Tests 2398 passed, 18 skipped (2416). No failure. The same counts held on f3bc62099, where the dogfood fixture this PR edits, email-template-materialization, also passed on its own (4 of 4).

Acceptance notes

  • Four plugin-email renderer tests (plain-text-faces.test.ts, send-template.test.ts, and two rows of template-loader.test.ts) render bodies this rule refuses. They feed the renderer directly and never cross an authoring door, so they stay as renderer inputs.
  • The refusal direction of the two scan corrections in 86330c090 (a character after a tag opener that opens no tag, and a script element's escape states) is not pinned by a dedicated test in this PR. The existing class-by-class rows and the renderer parity pin pass on that head.
  • runtime-gate.inert-type-writes.test.ts said the email_template mapping was proved only by a string assertion; its header now says the hole placement rule's door test reads that collection too, and that the table pin is still the whole proof for mapping.
  • An email_template write's finding is located positionally (emailTemplates[0].bodyHtml), not by name. emailTemplates is not a context collection, so the gate never re-keys it, and the written template is the candidate's only entry. where carries the template's name.
  • The notification half of 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 and the B3 export retirement are other cards and are not touched here.
  • Open PR feat(spec,lint): a bulk action's confirmText is a template slot over count and its params, judged at validate, compile and the publish gate #24947 also edits packages/lint/src/authoring-rules.ts, at a different entry. This branch merges cleanly with main at 2919ddb87 (git merge-tree, exit 0).

Generated by Claude Code

…le judge (#25189)

plugin-email's built-in template pin now reads the shared judge, and a
parity test holds the judge's hole grammar to the renderer's. The
metadata-protocol door test asserts the 422 INVALID_METADATA envelope for
an email_template write and that a draft is not judged.

Claude-Session: https://claude.ai/code/session_01Fg7vNwYgyAmHMTJm8rAYsD
Co-authored-by: Claude <noreply@anthropic.com>
… link shape; changeset (#25189)

The two docs examples, one dogfood fixture and two spec conversion
fixtures declare their link variable with type 'url' and place it as the
whole href value. The docs page states the supported placements.

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

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/formula, @objectstack/lint, @objectstack/spec, touching 37 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/email-templates.mdx (via emailTemplates (literal, a string literal in validateEmailTemplateHolePlacements), email_template (literal, a string literal in runtimeTypes))
  • content/docs/concepts/metadata-lifecycle.mdx (via email_template (literal, a string literal in runtimeTypes))
  • content/docs/deployment/environment-variables.mdx (via email_template (literal, a string literal in runtimeTypes))
  • content/docs/deployment/install-modes.mdx (via email_template (literal, a string literal in runtimeTypes))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES), email_template (literal, a string literal in runtimeTypes))
  • content/docs/getting-started/quick-start.mdx (via emailTemplates (literal, a string literal in validateEmailTemplateHolePlacements))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 19 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 — 145 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 15764cd723e750c01416cfe91b44bbd0a4c28f37 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 15764cd723e750c01416cfe91b44bbd0a4c28f37

⚠️ 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 15764cd723e750c01416cfe91b44bbd0a4c28f37 → 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: f3bc620994c73eda1842698f02103838228e4922
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts + packages/formula/src/mail-html-hole-placement.ts, packages/lint/src/validate-email-template-hole-placements.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/runtime-gate.ts, packages/cli/src/commands/lint.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/metadata-protocol/src/protocol.ts, packages/plugins/plugin-email/src/template-engine.ts, packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts, packages/plugins/plugin-email/src/template-loader.ts, packages/plugins/plugin-email/src/email-template-door.ts, packages/plugins/plugin-email/src/email-service.ts, .changeset/25189-email-template-hole-placement.md

Inputs: card #25189 (body and every comment), ruling 6335348115 on #25082 (items 1, 3 and 4), PR #25212 (body, file list, net diff against main), and the head's check-runs. Read-only: committed blobs at the head, nothing built, run or re-run. Disclosure (#9387, 5323310017) binds this record: every placement is named by class; nothing here reproduces a finding. The rest of the diff outside Scope: (four CLI transcripts, the dogfood fixture, the docs page's examples) is the lane seat's ACCEPT at TIER_DEFAULT.

Check-runs on the head, read at 2026-10-22T12:35Z: every completed run is success or skipped (opt-in and full-battery jobs), including Lint & Repo Gates, TypeScript Type Check, Build Core, Check Changeset and Governed Surface Queue Guard; the six Test Core shards were still in_progress and are reported as pending. The verdict below does not rest on them.

① Derived judgments

  1. The accept set (ruling item 3) — right. judgeMailHtmlHoles supports a double-braced hole in element text (the title and textarea elements' text included) and in a quoted attribute value read as plain text, and a triple-braced hole only as a variable the template declares with type: 'url' forming the whole value of a double-quoted href. That is F3 as ruled: context-strict, plus the built-in auth templates' raw-hole shape for every author. A triple-braced hole with a formatter, in a single-quoted value, as part of a value, on any other attribute, or reading an undeclared variable is refused.

  2. The allow-list against the renderer's encoding — right. template-engine.ts at the head encodes a double-braced hole one way (the five characters &, the two angle brackets and the two quote characters, as references) and writes a triple-braced hole unencoded; it is unchanged. That encoding is exactly right in HTML text, in RCDATA text and inside a quoted attribute value, and nowhere else, which is the supported set. Attribute values whose reading is a URL, a script, a style sheet or a document are refused by name (a URL-read list, the two element-specific URL-read attributes, every namespaced name, every name beginning on, style, srcdoc); I found no URL- or script-read attribute of the living standard or of legacy mail markup outside that list. Stated residual, not a defect: this half is a name deny-list over quoted attributes, so an attribute name none of us knows today that a client reads as a URL would be accepted; the tokenizer half, by contrast, refuses every unmodelled state.

  3. Fail-closed within the tokenizer model — right, with one exception that is item 7. Every state the scan does not classify as text or a quoted value answers tag; foreign content (SVG, MathML) is refused whole; raw-text elements (script, style, and their kind, plaintext included) are refused whole; comments, bogus comments and markup declarations are refused; a hole the scan would step over is refused rather than misaligned; a hole in an end tag's attribute is tag. The abrupt comment closings and the --! close match the specification.

  4. Hole grammar parity with the renderer — right. The hole pattern and the inner-body parse in mail-html-hole-placement.ts are the renderer's own, character for character (the classifier drops the formatter argument it does not need; the match/no-match decision is identical), and both scan left to right without overlap, so the spans the judge finds are the spans the renderer replaces, and a brace pair the renderer leaves verbatim is read as markup by both. text-field-html-outlets.pin.test.ts holds this against the real renderTemplate on a grammar corpus and on every built-in body, in both the empty-render and the marked-value directions. A hole visible to one side and not the other is not possible while that pin stands.

  5. Both doors, one code — right. The entry is commands: ALL, surfaces: CLI_AND_RUNTIME, runtimeTypes: ['email_template']; runtime-gate.ts already maps email_template to emailTemplates. os lint hands the registry a lowered parsed stack and runAuthoringRules falls back to normalized where none is given, so the parsed tier runs on all three commands. At runtime the one shipped caller, assertRuntimeAuthoringRules in protocol.ts, judges state: 'active' writes through evaluateRuntimeAuthoringGate, which throws the existing 422 INVALID_METADATA envelope with issues[]; the metadata-protocol test asserts code and status together. No error code is added. The finding is positional on the written template, which is the candidate's only entry.

  6. Drafts and stored items untouched (ruling item 4) — right. A draft save returns an empty verdict before any rule runs (D1, pre-existing). The send path reads email_template metadata through template-loader.ts and email-service.ts renders it with no judge in the path; the sys_email_template row door is closed to organization writes and the plugin no longer writes that table; the gate's baseline carries no stored template, so a stored template is judged only when it is itself published again. The plugin-email service test pins a refused stored row still rendering and sending. No migration, and none is owed.

  7. FAIL — the placement scan diverges from the HTML tokenizer in the accepting direction at two points, so a hole inside a raw-text element or a comment is accepted as element text. (a) In the tag-open state a character that opens no tag is consumed on the way back to the data state instead of being re-read there, as the specification requires; a tag, comment or raw-text element that opens immediately after such a character is therefore read as text, and a double-braced hole placed in that element or comment is classified element-text and accepted. (b) The script element's escaped and double-escaped states are not modelled: an end tag that the tokenizer does not honour inside the double-escaped region closes the element for the scan, and a hole after it, still inside the script element, is accepted as element-text. Both are refusal classes the header promises (raw-text-element, comment), both are reachable from an author's template with no value involved, and the fix is in the scan alone (re-read the character in the data state; model the script escape states, or refuse the element until its end tag outside any escaped region). The judge's class-by-class test should carry both shapes as refused.

  8. Public surface — right. @objectstack/formula gains the judge, its two tables and its sentence, with three types; @objectstack/lint gains the rule, its id and its finding type, and the rule count moves 50 to 51 (the four transcripts follow, found by check:docs-transcript-drift). The hits path, packages/spec/src/conversions/registry.ts, changes one conversion's fixture symmetrically (before and after both declare the link variable and use the href shape); expectedNotices and the conversion's behaviour are unchanged.

  9. The built-in pin — right. The hand-written regex pin is replaced by the shared judge, and the new control asserts that with no url declarations every built-in raw hole is refused, so a built-in can never sit where an author's template would be refused.

② Semver level

.changeset/25189-email-template-hole-placement.md: patch for @objectstack/lint, @objectstack/formula and @objectstack/spec, with the BREAKING banner, the FROM/TO/fix lines and one ADR-0087 marker (not-required, no migration prescription), and both declaration lines line-initial in the changeset and the PR body. Clause-②: no (narrowing) is true: an authoring accept set narrows and nothing is widened. Metadata-protocol: none is true as the ruling binds it (6335348115, "Metadata-protocol: none under A2"): the Zod shape of bodyHtml and every authorable key are unchanged, and the change is a lint verdict, not a schema change; check-changeset-no-major.mjs grades none as patch on every introduced entry, and the no (narrowing) beside none pair is neither of the two contradictions it refuses. Check Changeset is green on the head. plugin-email and metadata-protocol move tests only, so no entry is owed for them.

③ Boundary flags

  • open_questions is empty; confirmed.
  • Out-of-scope finding 1 (the stale sentence in runtime-gate.inert-type-writes.test.ts saying the email_template mapping is proved only by a string assertion): no card. A comment in a test file that the lint lane owns, no defect, contract violation or trap; a one-line correction rides this PR's fix push or the lane's next packages/lint PR.
  • Out-of-scope finding 2 (four plugin-email renderer tests render bodies the rule refuses): no card. They feed the renderer, which must keep rendering every stored body under ruling item 4; they cross no authoring door, and the body-taking render export is the B3 card on 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, not this one.
  • The five declared deviations (attribution block first in the PR body; no review request; the thread's branch name rather than an issue-numbered one; model-free commit trailers; one metadata-protocol test file and four transcripts beyond the claim): no card for any; the claim comment names the branch, the card-claims-branch check is green, and each is declared where the protocol asks.
  • Item 7 above: no new card — it is this card's own classifier, fixed on this PR with its tests; this record is re-rendered on the fix head.
  • One observation for the seat, no card from this record: the triple-braced href shape rests on the value of a type: 'url' variable, and the send path checks only that a required variable is present (requireVars), never that its value is a URL. F3 binds the shape for every author and the ruling's carried confidence gap covers the value side; a send-time check would be a renderer change A2 does not authorize (B2 was not taken), so it belongs to the director if a production reading ever warrants it.
  • Docs wording, not a card: the placement paragraph in content/docs/automation/email-templates.mdx lists every refused class but SVG and MathML content; a rider on the fix push.

Implemented-by: claude/project-thread-s4ghzp
Reviewed-by: session_01B2EeooonSXrANpkpgUSfFZ

VERDICT: FAIL


Generated by Claude Code

…en and fails closed in a script's escape states (#25189)

A character that opens no tag is reconsumed in the data state, and a
script's text stays raw from a comment opener until an end tag that
follows a comment closer. The inert-type-writes header now says the
email_template wiring is also read by the hole placement rule's door
test, and the docs' refused list names SVG and MathML content.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 86330c0908aa48dfcdd0c6abc8545c43667741f9
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts + packages/formula/src/mail-html-hole-placement.ts, packages/lint/src/validate-email-template-hole-placements.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/runtime-gate.ts, packages/lint/src/runtime-gate.inert-type-writes.test.ts, packages/cli/src/commands/lint.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/metadata-protocol/src/protocol.ts, packages/plugins/plugin-email/src/template-engine.ts, packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts, packages/plugins/plugin-email/src/template-loader.ts, packages/plugins/plugin-email/src/email-template-door.ts, packages/plugins/plugin-email/src/email-service.ts, .changeset/25189-email-template-hole-placement.md

Round 2 of the record 6340886113 on head f3bc62099, which answered FAIL on one item (two tokenizer divergences in the accepting direction). Inputs: card #25189 (body and every comment, the seat-written round-2 hand-back 6342008138 included), ruling 6335348115 on #25082 (items 1, 3 and 4), PR #25212 (body, file list, net diff against main and the delta f3bc62099..86330c090: two commits, four files), and the new head's check-runs. Read-only: committed blobs at the head, nothing built, run or re-run. Disclosure (#9387, 5323310017) binds this record: every placement is named by class; nothing here reproduces a finding. The diff outside Scope: (four CLI transcripts, the gate table in validating-metadata.mdx, the dogfood fixture, the docs page's examples) is the lane seat's ACCEPT at TIER_DEFAULT.

Check-runs on the head, read at 2026-10-22T13:34Z: every run is completed; every conclusion is success or skipped (opt-in and full-battery jobs, plus one superseded duplicate per re-triggered job), the six Test Core shards, Lint & Repo Gates, TypeScript Type Check, Build Core, Check Changeset and Governed Surface Queue Guard all success. Nothing pending.

① Derived judgments

Items 1 to 6, 8 and 9 of 6340886113 stand unchanged on this head: the delta touches no line of the accept set, the attribute classes, the hole grammar, the rule, the doors, the gate, the pin or the public surface, and the renderer is still unchanged. What this round judges is the fix to item 7 and the riders.

  1. Divergence (a), the tag-open state — cured. A character that opens no tag is now reconsumed in the data state (step('data', c)), as the specification's tag-open state requires, instead of being consumed on the way to data. Traced against the tokenizer: a tag, comment or raw-text element that opens immediately after such a character is now opened by the scan, so a hole inside it answers raw-text-element or comment and is refused; a hole inside the tag itself answers tag. For every character other than a further tag opener the two readings were already identical (text either way), so nothing that was element-text before and should be is reclassified.

  2. Divergence (b), the script element's escape states — cured, fail-closed. Inside a script element's text the scan now arms scriptEscaped at a comment opener and disarms it at a comment closer; while armed, an end tag does not close the element. Traced against the tokenizer: in the double-escaped region, where the specification does not honour the end tag, the scan does not honour it either, so a hole after that end tag is still raw-text-element and refused — the second reproduction is refused. The deliberate over-refusal is the escaped (not double-escaped) region, where the specification does honour the end tag and the scan does not until a closer has passed: a script element with an opener and no closer keeps the rest of the body as raw text. That is the direction that cannot admit a misjudged hole, it is stated in the code, and it is sound for this surface: a script element in a mail template is already refused whole, the built-ins carry none (the pin and the plugin-email suite are green on the head), and the measured HotCRM population had zero refusals before, which this change can only keep or raise, never lower, for bodies that place a comment opener inside a script element, a shape no mail client renders.

  3. Nothing else regressed — right. The flag is reset on every emitted start tag, is read only while the current raw-text element is a script element, and gates only the end-tag check, so the escapable-text elements (title, textarea) and the other raw-text elements behave exactly as before. The reconsume path goes through the data state's own case, so the only state it can produce is tag-open or data. The class-by-class judge test, the lint door test, the metadata-protocol door test and the renderer parity pin are unchanged and green.

  4. The riders — right. runtime-gate.inert-type-writes.test.ts now says the email_template mapping is read by the hole placement rule's own door test and that the table pin remains the whole proof for mapping only, which is the measured truth. email-templates.mdx now lists SVG and MathML content among the refused classes. The gate table in validating-metadata.mdx gains the rule's row with a new superscript for email_template and a longest-first note that keeps it apart from permission's; the docs gates are green.

② Semver level

Unchanged from 6340886113 and still true on this head: .changeset/25189-email-template-hole-placement.md grades patch for @objectstack/lint, @objectstack/formula and @objectstack/spec, with the BREAKING banner, the FROM/TO/fix lines, one ADR-0087 marker (not-required) and both declaration lines line-initial in the changeset and the PR body. Clause-②: no (narrowing) is true (an authoring accept set narrows, nothing widens). Metadata-protocol: none is true as the ruling binds it: the Zod shape of bodyHtml and every authorable key are unchanged, the change is a lint verdict; check-changeset-no-major.mjs grades none as patch and does not refuse the no (narrowing) beside none pair; Check Changeset is green on the head. The delta adds one test comment and docs, which owe no entry.

③ Boundary flags

  • open_questions: none in the round-2 hand-back; confirmed.
  • The stated gap: no dedicated refused-direction test row for either corrected divergence — accepted and stated, not a blocking FAIL. Why: the verdict a contract review owes on a security card is whether the classifier wrongly accepts a placement, and on this head, read against the tokenizer, it does not; what is missing is a regression pin for two branches whose refusal is now structural and documented at the branch. The exposure of the gap is bounded: both shapes are deliberate-author constructs no mail client renders, both branches are one condition each, the comment on each names the invariant, and the existing suites (the class-by-class rows, the closed-element row, the renderer parity pin, the two door tests) hold every neighbouring behaviour so a careless edit there goes red. The reason the rows are absent is a disclosure-shaped refusal in the dev's tooling, not a judgment that the shapes need no pin, and the PR's Acceptance notes state the gap by class. The rows are owed as a follow-up by whoever the maintainer names (the seat's hand-back places that with the maintainer); a filed card is not this record's to open while the maintainer holds the question, and the record names the debt so it is not forgotten: two refused rows in the judge's class-by-class table, one per divergence, in the same words the test file already uses for its other refused classes.
  • The earlier record's other boundary items stand: no card for the two out-of-scope findings (one is now fixed as a rider), none for the five declared deviations, and the observation on the url-typed value at send remains the ruling's carried gap, not a card from this record.
  • The two heads are on the same branch, the claim names it, and the card-claims-branch check is green on the new head.

Implemented-by: claude/project-thread-s4ghzp
Reviewed-by: session_01B2EeooonSXrANpkpgUSfFZ

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:spec seat 2 (#18549) · os-bill · session session_01B2EeooonSXrANpkpgUSfFZ · 2026-10-23T02:06Z · head 86330c0908aa48dfcdd0c6abc8545c43667741f9

  1. Shape: draft until the maintainer answered the open test question, base main, Fixes #25189 and Part of #25082 (never Fixes #25082) after the session's attribution block. 18 files. Executes ruling 6335348115 on 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 (letter F1 A2, items 1, 3 and 4).
  2. Premise measured: main has no placement check on email template holes. Producers before the edit: HotCRM at c8a7a7047feb, 76 HTML bodies and 116 holes, 0 refused; the templates this repository ships, 0 refused (the plugin-email pin asserts it per built-in). The five bodies the rule refused were non-production and now use the supported link shape.
  3. Gates: check-runs on this head 41 success and 8 skipped (opt-in, full-battery and superseded duplicates), none failed or pending. The dev's local dispatch-gates --ran: 113 derived, 113 exit 0, 0 NOT-MEASURED, 0 UNRUN. Dogfood on this head: command exit 0, 287 files pass / 3 skipped, 2398 tests pass / 18 skipped.
  4. Contract review: round 2 PASS at CONTRACT_REVIEW_TIER on this head, record 6342085020; it supersedes round 1's FAIL 6340886113 (two tokenizer divergences in the accepting direction, fixed in 86330c090).
  5. Ruling: the open question (review ③, the missing refused-direction rows) answered by the maintainer, letter 甲, recorded 6355640946: land now, file a test card.
  6. Accepted deviations and findings:
  7. Out of scope: the type: 'url' value is checked at send for presence only; that is the ruling's carried confidence gap and a renderer change A2 does not authorize, so no card (round 1 ③). The notification half of 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 and the B3 export retirement are other cards.
  8. Landing: pr_ready + automerge_enable through the relay now. The seat follows it to MERGED.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 23, 2026 02:07
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 23, 2026 02:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 23, 2026
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint: an error-tier authoring rule refuses an email_template body hole the renderer cannot encode for its placement (#25082's email half, ruling F1 A2)

2 participants