Skip to content

feat(email): MJML slot/component composition + interpolation — combined draft (bindings for comment) - #74

Draft
agreenspan wants to merge 27 commits into
mainfrom
claude/email-interpolation-slots-pnnkti
Draft

feat(email): MJML slot/component composition + interpolation — combined draft (bindings for comment)#74
agreenspan wants to merge 27 commits into
mainfrom
claude/email-interpolation-slots-pnnkti

Conversation

@agreenspan

@agreenspan agreenspan commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What this is

Draft, for comment. Combines the built MJML slot/component render engine with the converged design (COMM-009), the cascade-diff decomposer, and a declarative (bindings-based) registry.

Update (2026-07-06) — the two design pieces are now built + tested

  • Cascade-diff decomposer (packages/email/src/render/decompose.ts, pure, 13 tests). Walks the parseBlocks AST; per component ref, partitions caller overrides (kept inline, caller-owned) from the component's own body (chrome + :default slots), diffs the body against an injected cascade resolver → noop/inherit (unchanged) vs write (new/diverged), attributing nested refs by region (ref in a :default → owned by the enclosing component; ref in an override → bubbles to the caller). Replaces mapRefs/resolveVariants variant-indexing — no slug:idx, no fork-suffixing; fork = a new slug (an FE rename). Covers the parent-ships-child-pre-filled nesting regression + child-first write ordering.

  • Declarative registry via json-rules bindings (apps/api/src/lib/email/registry.ts + new resolveEntry.ts, 13 tests). EmailEntry is now serializable data — closures gone: entity/recipient where carry {bind} tokens + a bindings map (each value's context path); sender is a typed spec with bound id fields; data is a path-projection map. Pure resolveEntry fills binds from the ordered context (data → entity → sender → handoff) and calls resolveLensBindings/resolveBindings; the planner (sendEmail) is wired to it. Unlocks the serializable registry COMM-010 deferred + a statically-derivable lens surface.

    • Correction: {bind} ships in the pinned json-rules 2.14.1 (landed 2.11.0), not an unreleased 3.0.0; it resolves as a flat key into a bindings map (the resolver fills paths, json-rules does not path-navigate).
  • decompose wired into save.ts (collectSlugs + decompose). saveEmailTemplate now decomposes the hydrated payload against the owner cascade: an inlined body equal to the resolved cascade body is a noop/inherit (no write); a divergence (or unknown slug) writes the same slug at the current tier (shadow) — no slug:idx variants, no fork-suffixes. save.test.ts rewritten to the noop/shadow/no-variant model (+ explicit org-shadow coverage). The superseded extractRefs (mapRefs) + resolveVariants modules were removed.

  • Fixed a pre-existing enqueue ↔ handlers import cycle (on main, backmerged here). Jobs re-enqueue jobs, so handlers import enqueueJob and the registry imports every handler — enqueue's static registry import closed an eval-time loop that TDZ-threw whenever a single handler module loaded before handlers/index.ts (e.g. a handler's own test). enqueue now lazy-loads the registry at its single call site (JobPayloads stays a static type). This unblocked sendEmail.test.ts, which was previously unrunnable.

  • Migrated sendEmail.test.ts to the declarative registry — now 7/7, exercising the bindings resolver end-to-end through the planner + DB.

Verified: full email render suite green (113, incl. decompose + save.test.ts DB integration) + 32 api email units (registry + resolveEntry + sendEmail handler). No type errors in touched files.

Built earlier (this branch, render foundation)

  • parseBlocks.ts — pure parser for {{#component:slug}} / {{#slot:name}} / {{#slot:name:default}}.
  • renderBlocks.ts — recursive render, overrides.get(name) ?? node.children (empty-default-holds-position).
  • expand.ts — thin wrapper + per-slug-memoized cascade loader.
  • interpolate.ts — deep-path support with a __proto__/prototype/constructor guard + reserved system lens; unsubscribeUrlsystem.

Related

  • Template: COMM-009 (slots + grammar), COMM-010 (send-governance matrix — storage/validation shipped, enforcement deferred), COMM-011 (multi-lens recipients — backlog).
  • Zealot: ZLT-3271 (engine port target) + ZLT-3272 (builder + lens model).
  • Builder mockup (converged model): https://claude.ai/code/artifact/62c92778-88b1-4196-b435-6b9847942876

claude and others added 17 commits July 3, 2026 23:32
COMM-009 foundation, TDD. Adds the pinned slot grammar and the render path
that consumes it; interpolation gains a `system` lens.

- parseBlocks: pure DB-free parser, tokenizes {{#component}}, {{#slot}},
  {{#slot:name:default}} into a text/component/slot node tree. Syntax only —
  ownership (override vs injection) is decided by consumers. Interpolation and
  {{#if}} stay opaque.
- renderBlocks: pure render core with an injected component-body loader. Per
  ref: collect caller override slots, load body, inject override at each slot
  marker else render :default, recursing. Empty default holds position.
- expand: now a thin wrapper over renderBlocks with a cascade-backed, per-slug
  memoized loader (dedups the old N+1). Refs are discovered from the parse tree
  (single source of truth = the MJML), so the redundant componentRefs arg is
  dropped; callers updated (compose ×2, save, emailVersioning hook.test ×2).
- interpolate: rename VariablePrefix -> Lens, add `system` lens alongside
  sender/recipient/data. Conditionals pick it up via flattenVariables.

Tests: parseBlocks (9), renderBlocks (8), interpolate +system (22); DB-backed
compose/save (30) and emailVersioning no-drift (6) green. Ticket updated with
the recomposeSnapshot slot-drift follow-up and the decided lens taxonomy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…e pass

Verified parseBlocks/renderBlocks are lenient by design; enumerate the exact
malformed cases the save-side slot validator must reject (bare passthrough text
dropped at render, duplicate override names last-wins, unbalanced/crossed tags).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
The unsubscribe link is platform-injected, not recipient data — it belongs on
the system lens. Non-system templates now must carry an unconditional
{{system.unsubscribeUrl}} (save-time compliance check). settleTemplate's
per-kind var injection targets the system lens (recipientVarsForKind ->
systemVarsForKind).

save.test + interpolate green (40). Doc updated. (sendEmail.test has a
pre-existing, environment-specific circular-import load error unrelated to this
change — reproduced identically at HEAD.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…-010 slice 1)

Each template can declare a sender→recipient send matrix as a serializable
@inixiative/transitions Action ({ paths: [{ from, to }] }) — from = sender side,
to = recipient side. Guard-only governance, tenant-configurable in the DB.

- schema: EmailTemplate.matrix Json? (absent = no restriction)
- validateMatrix/assertValidMatrix: pure, domain-agnostic structural floor —
  well-formed Action, each path a serializable transition (valid json-rules
  predicates + valid ActionRule permission shapes) via validateTransition, no
  lens yet (lens-scoped checks are the api boundary's job, slice 2).
- wired into saveEmailTemplate alongside the MJML/conditions validators.
- @inixiative/transitions added to @template/email (generic primitive, like
  json-rules).

Tests: validateMatrix (10) + save persist/reject (2 new); 81 green across the
touched render surface. Design + slice plan in tickets/COMM-010.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
Define the sender/recipient asymmetry (sender = polymorphic model map,
discriminated selection; recipient = always a User leaf + additive provenance
overlay). Recipient defined: required User(id,name,email) leaf, optional
provenance (organizationUser→organization / space parallel) bound from the send
context, not walked from the user. Composition is an ordered, context-threaded
pipeline (data → sender select+bind → merge → recipient bind → assert leaf →
interpolate) that mirrors transitions' from→merge→to — guard and composition
walk the same edge. Reslice: add 1b (lens-keyed matrix) + 2b (composeLenses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…nditional composition

Correct the recipient model: cardinality (one sender vs a recipient SET) is the
real asymmetry, not User-ness. Recipient side = a name-keyed map of set-valued
LensNarrowing queries, OR-ed by the matrix `to`; multiplicity lives in the lens
(where = filter/level, binding present/absent = scope one-org-vs-all,
lens key = polymorphic type). Generalized leaf = email + Contact (User or
external Contact); recipient set = eligible(toLenses) bound to context.

Lens keys are unique descriptive names (parent model declared inside),
convention model-first + modifier-when-disambiguating; both sides uniform maps.

The declared lenses are one field vocabulary for interpolation, {{#if}}
conditionals, slots, and the guard — closing the lens-aware-validation gap
COMM-009's validateConditions explicitly parked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…lations

Walk back the polymorphic-parent overreach. Recipient root = User (the person);
org context, consent/address (User→contact), space, provenance all hang off the
User via relations — nothing is a different root. Still set-valued (fan-out):
"all org users"/"of this level" are relation-navigating where clauses; a
polymorphic customer ref resolves DOWN to its User(s). Asymmetry sharpened to two
axes — root (sender polymorphic model vs recipient always-User) and cardinality
(one vs set).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
Lens selection happens once at the planner (sendEmail = send→deliver bridge).
The lens is the hydration boundary (fetchLens+prune) and therefore the logic
boundary — field/logic leakage structurally impossible. Encoding: the handoff
already serializes prune(user, lens); extend it to prune-to-assigned-lens +
a recipientLens key tag; lens definitions stay on the template. Collisions:
logic/field enforced by prune (free); identity enforced by precedence-dedup by
identity before the plan (existing idempotencyKey+skipDuplicates already collapse
same-email, but winner is fetch order — precedence makes it deterministic). Key
uniqueness free from object-map encoding. Slice 3 updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
Reshape send governance from inline-predicate matrix to the name-keyed lens
model the design converged on.

- schema: EmailTemplate.lenses Json? ({ senders, recipients, data } name-keyed
  maps); matrix reshaped to { paths: [{ from: senderKey, to: recipientKey[] }] }.
- validateLenses (pure, structural): each lens declares a parent model + valid
  json-rules `where`; recipient lenses must be parent: User with the id/name/email
  delivery leaf (recipient root is always User, reason out via relations).
- validateMatrix(matrix, lenses): matrix keys are lens references — cross-check
  every from ∈ senders and every to ∈ recipients; non-empty paths/to.
- both wired into saveEmailTemplate; domain-agnostic (model/field catalog checks
  are the api boundary's job, slice 2).
- remove @inixiative/transitions from @template/email: structural validation is
  json-rules-only; the checkTransition enforcement engine belongs at the api
  boundary (slice 3), not the domain-agnostic render package.

Tests: validateLenses (9) + validateMatrix (11) + save persist/reject (3); 40
green across the governance surface, 39 pure render/interpolate unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…keeper, matrix is the maybe

Pause point. The lens's primary job (safe-navigation interpolation surface —
"what data shows up, who sees what") is the load-bearing, shipped value. The
sender×recipient matrix multi-modality / multi-lens-per-template / precedence is
the speculative part — "different template per recipient type" may be the simpler
right answer. Don't build slices 2b/3 until the one-vs-many-templates call is
made. Locked: governance-only, two-layer authoring (tenants select options, never
compose lenses), system-emails-first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…e recipient lens

Simple model wins: evolve the existing EmailEntry code registry (not the DB
config) with one sender and one recipient lens per template — one path, one
hydration boundary per side, no lens-selection logic. Different audiences =
different templates via the existing multi-handoff bridge. DB lenses/matrix
columns stay modeled but dormant. Interface upgrade: static recipient
picks/relations (the save-time-knowable interpolation surface) + dynamic
where(entity, sender) only. Multi-lens union/precedence/tenant-editable
governance parked in COMM-011 with the join-is-the-real-target insight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…B matrix/lenses

Settle on the simple model: code registry, one sender + one recipient lens per
template. Rolled back the DB-config experiment (columns + validators removed —
no migrations existed; design preserved in COMM-010/011 and git history).

- registry: RecipientDefinition splits static from dynamic — picks/relations
  declared statically (the template's recipient interpolation surface, knowable
  at save time), only where(entity, sender) is a closure. recipientLens()
  assembles the User-rooted narrowing, so the recipient-root-is-User invariant
  and the hydration boundary are enforced by construction. Entries migrated via
  a userRecipient helper.
- sendEmail planner builds the lens from the definition (fetchLens/prune flow
  unchanged); test fixtures migrated.
- registry.test.ts: lens assembly, relations passthrough, delivery-leaf
  invariant across all entries, entity-driven where.
- schema: drop EmailTemplate.lenses/matrix; remove validateLenses/validateMatrix
  + save-path wiring and exports.

Validation: email package 108 pass; registry.test 4 pass; emailVersioning 6
pass. (sendEmail.test.ts still carries its pre-existing, environment-specific
module-load error — fixtures updated for the new shape regardless.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
Walks the parseBlocks AST and, per component ref, partitions caller overrides
(kept inline, caller-owned) from the component's own body (chrome + :default
slots, diffed against the cascade). Body == cascade → noop/inherit; diverged or
new → a child-first component write. Nested refs attribute by region: refs in a
:default are owned by the enclosing component; refs in an override bubble to the
caller. Replaces the mapRefs/resolveVariants variant-indexing — no slug:idx, no
fork-suffixing. Pure + DB-free (injected cascade resolver); 13 tests incl. the
parent-ships-child-pre-filled nesting regression + child-first write ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…res)

EmailEntry becomes serializable data: entity/recipient where-conditions carry
{bind} tokens with a bindings map declaring each value's context path; sender is
a typed spec with bound id fields; data is a path-projection map. New pure
resolveEntry (resolveEntity/resolveSenderIdentity/resolveRecipients/resolveData)
fills bind values from the ordered context (data -> entity -> sender -> handoff)
and calls resolveLensBindings/resolveBindings. Planner (sendEmail) wired to the
resolver. Serializable registry + statically-derivable lens surface, no opaque
closures. 13 pure tests (registry + resolveEntry); email render + save DB suites
still green (70) + api email units (25).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
saveEmailTemplate now decomposes the hydrated payload against the owner cascade:
per component, an inlined body equal to the resolved cascade body is a noop
(inherit, no write); a divergence (or an unknown slug) writes the SAME slug at
the current tier (shadow) — no slug:idx variants, no fork-suffixes. collectSlugs
batches the cascade lookup. Rewrote save.test.ts to the noop/shadow/no-variant
model (+ explicit org-shadow coverage). Removed the superseded extractRefs
(mapRefs) + resolveVariants modules and their exports. Full render suite green
(111) incl. save DB integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The handler test built entries with the old closure shape (entity:(data)=>lens,
sender:()=>..., RecipientDefinition.where closure). Ported to the declarative
EmailEntry: entity {narrowing+bindings}, sender spec, RecipientSpec with {bind}
where + a bindings map (literal where values for fixed id-sets / cc). This path
was unrunnable until the enqueue import-cycle fix; now 7/7 pass, exercising the
bindings resolver end-to-end through the planner + DB (fan-out, cc, logging,
idempotency, opt-out, unsubscribe headers, undeliverable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@stevenolay stevenolay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three inline notes on the open findings: parseBlocks robustness, silent bind resolution, and the inquiry picks binding. Full summary in the top-level comment.

current().push(node);
stack.push({ node, children: node.children });
} else {
stack.pop();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stack.pop() closes whatever block is currently open without checking it matches the close tag, so malformed input corrupts the tree silently (no error). Three cases:

  • Mismatched close: {{#component:card}}hi{{/slot:x}}tail{{/component:card}}. The {{/slot:x}} pops the card early, so tail and the real close land outside the card.
  • Unclosed open: {{#component:card}}body swallows the rest of the document.
  • Stray space: {{# component:card }} isn't matched by TAG (no \s), so it stays literal text, but a clean {{/component:card}} still pops a frame that was never pushed.

This got more load-bearing after 2a1f677, since collectSlugs runs parseBlocks on the save path now too, so a malformed tag can corrupt what gets persisted, not just what renders. Suggestion: on pop, assert the top-of-stack kind and name match the close (else throw), and assert an empty stack at the end (unclosed-block error). That turns silent corruption into a save-time validation error.


// Fill a bind-name → value map by reading each declared path from the resolution context.
const fill = (sources: BindSources, context: Record<string, unknown>): Record<string, RuleValue> =>
Object.fromEntries(Object.entries(sources).map(([name, path]) => [name, get(context, path) as RuleValue]));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A mis-declared bind path resolves to undefined here, which becomes WHERE <field> = NULL downstream: zero recipients, no email, no error. fill does get(context, path) per bind, so a wrong or renamed or unthreaded path yields undefined, resolveBindings emits null, and an equals recipient where matches no rows.

Good news, and I checked this against json-rules directly: it fails closed (or throws loudly if the bind name is entirely missing), never "match everyone", so no leak, just a dropped send. The gap is that json-rules ships requiredBindings / validateBindNames for exactly this and nothing calls them, and the registry invariant test only asserts bind names are declared, not that they resolve, and only for recipients. A resolve-time requiredBindings assert across entity/sender/recipients/cc/bcc would make a typo'd path fail loudly instead of silently sending to nobody. registry.ts:83 is a live example of how easy this is to hit.

}),
bindings: { inquiryId: 'data.inquiryId' },
},
sender: { type: 'Organization', bindings: { organizationId: 'entity.sourceOrganizationId' } },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This binds entity.sourceOrganizationId, and :84 binds the recipient from entity.targetUserId, but neither field is in the entity picks (['id', 'content', 'sourceOrganization'] on :77).

It resolves today only because fetchLens issues findMany with no select, so the raw row happens to carry every scalar, and sendEmail passes that raw row (not the pruned projection) to the resolvers. Add a select to fetchLens, or switch the resolvers to the pruned entity, and both binds go to undefined, then null: sender organizationId: null and recipient matches nobody, silently (per the resolve-time gap on resolveEntry.ts:20). Either add these two scalars to picks, or pin the raw-row dependency with a comment so a future select doesn't quietly break addressing.

@stevenolay

Copy link
Copy Markdown
Contributor

Did a full pass over the PR (render pipeline, decomposer, registry/resolver, removed-behavior and security), verifying each finding against the code. The hard parts hold up well: nested-ref attribution, child-first write ordering, and the noop/inherit diff all check out, and the security surface is clean (the __proto__/prototype/constructor guard and the server-only, un-spoofable system lens).

Nice to see a few already resolved by the recent commits:

  • The split-brain save path (old indexer wrote, new engine validated) is closed now that decompose is wired into save (2a1f677), and the same-slug shadow model is explicit and tested. Only residual: divergent duplicate bodies under one slug collapse silently to last-wins (bySlug.set), which is fine if the builder guarantees one-slug-one-body per payload, otherwise an optional defensive assert.
  • The sendEmail handler test is migrated to the declarative registry (ff98516), so the addressing path has real coverage again.

Three open findings, details inline:

  1. parseBlocks silently corrupts structure on malformed tags (parseBlocks.ts:39). Close tags pop the stack without a kind/name match, and it is now load-bearing on the save path via collectSlugs. A balance and kind check turns it into a save-time error.
  2. Silent bind failure (resolveEntry.ts:20). A mis-declared path resolves to WHERE = NULL, so no recipients and no error. It fails closed (no leak), but requiredBindings exists and nothing calls it.
  3. The inquiry entry binds fields not in its picks (registry.ts:83-84). Works only by luck (raw-row findMany), one select away from silently emailing nobody.

None are blockers and the direction is solid. Context on our side: since ZLT-3271/3272 port these exact modules, we inherit the shadow/last-wins model as-is, and I would want the parseBlocks guard and the bind assert regardless.

agreenspan and others added 10 commits July 13, 2026 18:04
Collapse the two-pass render (evaluateConditions → trailing global
VARIABLE_PATTERN replace) into one recursive walker. `settle(content,
scope, { substitute })` handles {{#if}} branches and token substitution
in a single pass — substitute:false is evaluateConditions, substitute:true
is interpolate, both now thin wrappers. A substituted value is emitted at
its own scope depth and never re-scanned.

Scope is one flat {sender, recipient, data, system} object threaded
through recursion — the seam {{#each}} extends per element (COMM-010).
check() receives the nested scope directly (json-rules resolves dotted
fields), dropping the flatten step.

Behavior-preserving: interpolate + evaluateConditions suites green (36),
full email render/save suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-010)

Adds {{#each path as=name index=i filter={...}}}...{{/each}} loop grammar
to the render engine. Loops desugar to element scopes {...scope, [as]:element}
walked by the same settle() pass that handles {{#if}} and interpolation, so
loop bodies get conditionals, nested loops, and token substitution for free.

- conditionParser: readEachMarker (tolerant attribute parsing), kind-stack
  body matcher (findEachBodyEnd) for correct nesting of {{#if}}/{{#each}},
  reserved binding-name guards.
- settle: settleEach resolves the path, validates as=/index=/filter=,
  applies the json-rules filter predicate per element, emits per element.
- 11 tests: basic, index, nesting, filter, if-in-loop, empty/non-array sink,
  object-value token-visible+sink, collision/missing guards, loop-free identity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The design's stated "real gate" for loops: settleEach only sinks malformed
blocks at render time, so save-time validation is the actual defense. Ports
Zealot ZLT-3326's each validation into template's structural floor (lens-aware
field validation stays out until the builder lands, as before).

- validateConditions now scans {{#each}} alongside {{#if}}: attribute errors,
  as=/index= identifier + reserved + enclosing-binding collisions, index===as,
  each-path root must be a reserved root or an enclosing as=, filter JSON +
  json-rules structural validation. Binding scope threads through nesting.
- isSubject option bans {{#each}} in subject lines (conditionals still allowed);
  save.ts threads it for subject validation.
- collectStraddleIssues + isStructurallyBalanced: an if/each block whose open
  and close straddle a component ref's own body would desync on decompose —
  now rejected at save.
- 15 validateConditions tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
saveComponent previously punted MJML validation for component bodies —
"the document validator can't see fragments." Ports Zealot ZLT-3326's answer:
wrap the fragment in each MJML context it can legitimately live in (body,
head, attributes, column, navbar, social, accordion, carousel) and accept the
first that validates; reject full <mjml>/<mj-body> documents outright.

- validateComponentMjml in saveComponents.ts, called at the unit boundary.
- 2 tests: full-document body rejected (MjmlValidationError), unknown-tag
  fragment rejected; existing valid-fragment saves unaffected (20 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A component slug matches ^[a-z0-9-]+$, which includes `constructor`,
`__proto__`, `toString` etc. The per-tier lookup maps and the cascade merge
were plain `{}` with truthy `map[slug]` access, so an ABSENT slug named like
an Object.prototype key resolved to the inherited member (a truthy function)
instead of undefined — a false-positive that crashes validateNoCycle
(`for (const ref of component.componentRefs)` on a function) and mis-resolves
the cascade. Build the maps with Object.create(null) and probe with
Object.hasOwn, matching the guard template already applies on the
interpolation path (settle.ts UNSAFE_PATH_SEGMENTS).

Ports Zealot ZLT-3326's lookup hardening. lookupCascade.test.ts proves the
regression (fails on plain-object maps, passes with null-proto).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parseBlocks (the render parser) is deliberately lenient — a stray/mismatched
close pops the stack regardless of kind/name, an unclosed open swallows the
document's tail as that node's children — so a malformed payload silently
corrupts stored MJML and the cascade-diff instead of failing. Adds a separate
strict validateBlocks over the same grammar, run at save, that 422s instead.

Ports Zealot ZLT-3326's parseBlocks hardening as a standalone validator
(keeping template's render parser lenient, per COMM-009): stray_close,
mismatched_close (kind+name checked), unclosed_open, invalid_slug (incl.
whitespace-spaced / non-canonical tags), invalid_modifier (:default on a
component tag), duplicate_slot (a ref filling one override slot twice — the
silent-last-wins hole renderBlocks' overrides.set shares). Wired into save.ts
(template payload) and saveComponents.ts (each component body).

Not ported: Zealot's parser has no "bare text inside a component ref" rejection
(the COMM-009 ticket lists it but Zealot allows it) — left for a design call.

- validateBlocks.ts + 15 tests (each reason + valid nesting/default-slot cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
validateBlocks flagged duplicate_slot the moment the second slot closed, so on
an already-malformed ref (a duplicate PLUS a later mismatched/stray/unclosed
error) it surfaced duplicate_slot where Zealot surfaces the structural reason.
Accept/reject was already identical (both 422); this aligns the typed .reason
discriminant. Record the duplicate on the component frame and throw at the ref's
close, after the mismatch check — so an inner structural error takes precedence,
exactly as Zealot's assertNoDuplicateOverrideSlots (runs at component close).

- 2 precedence tests (unclosed and mismatched-close both outrank the duplicate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…flow

saveTemplate and saveComponent carried byte-identical scoped-upsert blocks
(resolve by natural key within owner scope, then update-or-create). Extract
the flow into one saveScopedRow helper. Two-stage find→mutate rather than a
Prisma upsert: the natural-key uniques are partial (WHERE deleted_at IS NULL),
which upsert/ON CONFLICT can't target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The same component slug carrying two different inlined bodies in one save
payload was silently collapsed to last-wins. Refuse to guess which body the
author meant: throw DivergentDuplicateSlugError. An identical duplicate
(byte-for-byte same body) still collapses to one write — no ambiguity there.

Fold the divergence + identical-collapse into decompose via bodiesSeen, so
`writes` is unique per slug and save.ts drops its bySlug collapse. save.ts now
parses the payload once (decomposeNodes + collectSlugsFromNodes off one tree).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecompose (COMM-009)

decompose strips a ref's inlined body out to the component's own row; hydrate
injects the cascade-resolved body back in, keeping overrides as-is — the read
transform a single-pane editor loads. decompose(hydrate(x)) on an unedited
payload is a true round-trip (zero writes, same mjml), the property the tests
assert. Dangling refs stay bare (a read degrades passively, unlike a send-time
expand); persisted cascade cycles are bounded with EmailRenderError.

Follows the template's expand shape (OwnerScope + direct lookupCascade), not
Zealot's composition-context overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants