feat(email): MJML slot/component composition + interpolation — combined draft (bindings for comment) - #74
Conversation
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
left a comment
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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, sotailand the real close land outside the card. - Unclosed open:
{{#component:card}}bodyswallows the rest of the document. - Stray space:
{{# component:card }}isn't matched byTAG(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])); |
There was a problem hiding this comment.
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' } }, |
There was a problem hiding this comment.
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.
|
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 Nice to see a few already resolved by the recent commits:
Three open findings, details inline:
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 |
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>
Carries main's reserved {{system.now}}/{{system.year}} tokens onto the settle()-based
interpolate as a pre-pass (non-overridable, before conditionals and substitution), keeps
the caller-supplied system bucket for rail-provided values, and merges both
prototype-key lookupCascade tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…he fields it binds A mis-declared or unthreaded bind path resolved to undefined, which json-rules turns into WHERE field = NULL: no recipients, no error, a silently dropped send. Every bind now goes through one fill that throws UnresolvedBindError naming the bind and path; the sender resolver uses the same fill instead of its own get. The inquiry entry bound sourceOrganizationId and targetUserId without picking them — it worked only because fetchLens returns raw rows. Both are now picked, and a registry invariant test pins that every entity-rooted bind names a picked field. Addresses the two open review findings on #74 (resolveEntry.ts:20, registry.ts:83). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…e single guarded walk
Parity with Zealot's engine (ZLT-3271 5088a1360, #2112):
- parseBlocks throws a typed ParseBlocksError (mismatched_close, stray_close,
unclosed_open, invalid_slug, invalid_modifier, duplicate_slot) instead of popping the
frame stack blind, so a malformed tag is rejected wherever the grammar is parsed —
collectSlugs, decompose, hydrate, expand — not only behind a separate save-time gate.
validateBlocks is folded in and deleted; the whole-body assertNoDuplicateExposedSlots
(shadowing-aware) runs at the component save site. Addresses the open review finding
at parseBlocks.ts:39.
- expand owns the render walk: override scope chain ({ overrides, path, parent } — a fill
renders in the scope that authored it, one hop up, so a slot re-exposed through a nested
component's override still receives the grandparent's fill), render-time circular_ref
guard on the path, one lookup per level and one parse per slug. renderBlocks was a
second unguarded copy of the same walk and is deleted. expandWith takes the component
loader so recompose can render pinned snapshot bodies through the same engine; expand
binds it to the owner cascade.
- hydrate round-trip test for the re-exposed slot: decompose(hydrate(row)) stays zero-write.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…326 #1689)
- limits: 100 elements per loop, 2 levels of nesting. Over either cap the block sinks an
error and renders nothing — never a silent truncation; save-time validation rejects the
depth breach.
- validateConditions desugars a leaf's binding-rooted field/path to its absolute path
(m.tier inside {{#each recipient.memberships as=m}} → recipient.memberships.tier) before
checkRuleAgainstLens, walks each logical leaf independently so one valid binding cannot
suppress a sibling, and reports unknown binding roots per leaf. The lens rides in as
options.lens; no caller passes one yet.
- conditionParser owns RESERVED_SCOPE_ROOTS/TOKEN_PATTERN (settle's copies deleted) and
reads whitespace-tolerant rule markers.
- rules/walkConditionTree + rules/resolveBindingPath shared by validation.
- Tests for every posture the review locked: bare binding token, non-primitive binding
sinks, filter throw deduped, post-filter index, straddle, caps, desugar.
Template keeps its four scope roots (sender, recipient, data, system): system is both the
clock pre-pass namespace and the rail-provided bucket here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…ocks split into nodes, tags and validators - src/errors/: one file per error class (EmailRenderError, ParseBlocksError, ConditionValidationError, DivergentDuplicateSlugError, MjmlValidationError) - src/validations/: validateConditions, validateNoCycle, assertNoStrayTagShapes, assertNoDuplicateExposedSlots, assertNoDuplicateOverrideSlots join validateMjml - render/nodes.ts (node types + collectSlugsFromNodes) and render/blockTags.ts carry what parseBlocks used to own; expand prefetches through the shared walker - conditionParser: one parseRuleJson helper, no instanceof guard on JSON.parse - interpolate: drop the unused Lens enum (the four roots are RESERVED_SCOPE_ROOTS) - registry: rule literals instead of a cast eqBind helper - decompose/save: comment walls removed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…e folders of atomic files One exported function per file, index.ts barrels keep every import path working, and the last function declaration (settle) is an arrow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…; drop the bind side tables Registry entries are lenses plus a sender pick. The entity lens binds from the handoff, the sender ids and recipient where pick off the entity row, cc/bcc off the recipient row. BindSources, EntitySpec, fill and UnresolvedBindError are gone; bindLens/bindWhere assert every required name is supplied and hand the engine a flat map. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
…h-close is inert like if; comment walls cut
- saveScopedRow matches only live rows, so re-saving a soft-deleted slug
creates a fresh row instead of reviving the tombstone (test added)
- SCOPE_ROOTS/ScopeRoot in the grammar; Variables derives from it
- the render-time orphan {{/each}} sink is gone: save-time balance checks
already reject it, so the two orphan closes now behave the same
- explanatory comment walls removed from hydrate, compose, types, saveComponents
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
…ort from their new homes; registry entries are lenses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
…ort from their new homes; registry entries are lenses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…ort from their new homes; registry entries are lenses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
…ort from their new homes; registry entries are lenses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
…urface, scope hooks (#97) * chore(email): rules-builder 0.27.0 for the headless authoring hooks; mjml-preset-core for the nesting table Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(db): EmailTemplate.lens and EmailComponent.expectations The authoring lens lives on the slug's default-tier row as per-slot narrowings over the projection the system provides; tenant rows inherit it through the cascade. A component's expectations are the absolute paths its body demands, derived at save and never authored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(email): the projection the system provides, the lens the row holds emailProjection composes a synthetic-root lens (EmailRuleContext → recipient, sender, data) over the whole field map: what is reachable for a template, with no narrowing of its own. emailLens applies the row's per-slot narrowings (engine defaults where the row is silent: recipient = the delivery leaf, sender and data = their scalars) and emailSurface exposes the result for the builder. emailRuleDecoration derives one facet per root relation. Ported from Zealot's rules layer (#1689, #1655, #2171): collectHydrationPaths, walkLensPath, componentExpectations (system.unsubscribeUrl is the rail-provided field here, not a recipient field), collectJsonOpacityWarnings. Component save derives expectations from the body. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(email): headless authoring surface — regions, MJML nesting table, authoring barrel Ported from Zealot (#1698, #2090): collectComponentRegions, collapseComponentBodies, removeSlotOverride, slotDefaultContent, canNestMjml/MJML_CHILD_TAGS, and the authoring barrel the editor consumes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(api): admin emailTemplate ruleSurface — the authoring surface for a slug POST /api/admin/emailTemplate/ruleSurface returns the projection for the slug (sender model and data shape from the registry entry; recipient-only for a slug the registry does not know) narrowed by the lens on the slug's default-tier row, plus the decoration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(ui): headless email authoring hooks useEmailRuleSurface fetches a slug's surface. useEmailVariableScope resolves it through rules-builder and walks it as a scope stack: values are copyable tokens, to-many relations are loop portals; entering one re-anchors the scope at the element model and wraps every value copied inside in the enclosing {{#each}} blocks with collision-free kebab bindings. No components — the forms come later. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * docs(COMM-012): adhoc templates — the projection the system provides, the lens the row holds Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * fix(email): data is unknown by construction — an opaque Json root, addressable at any depth The projection had bound data to a declared shape, which is not what data is: the per-template payload nobody can declare ahead of the event. Its root is now a Json field — every {{data.…}} path and every rule beneath it is addressable, and validation reports it as beneath Json (a warning), never missing. A registry entry may still overlay a declared shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(email): the data slot is a lens too — the row chooses its entry point and relations Each slot on the row is its own lens built from the projection: recipient's entry point is User, sender's is the sender model, and data's is whatever the author chooses (lens.data.model) with relations beneath it (lens.data.narrowing). No entry point = the unknown bag. COMM-012 records the perspective question and the palette/shell plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * chore(email): follow the #74 restructure — errors, nodes and tags import from their new homes; registry entries are lenses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP * docs(COMM-013): email errors and degraded rule behaviour — problem statement Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP * feat(email): deliverability preflight — content checks and a draft preflight action (port of Zealot #2171) Content checks over a rendered draft, network-free and observation only: subject, preheader, unsubscribe link, image alt text, tokens the lens cannot resolve, render warnings and spam-trigger phrases. Each check is `(input) => PreflightFinding[]`, injectable into `runPreflight`, which settles them all and orders findings errors-first with counts. `POST /api/admin/emailTemplate/preflight` renders an unsaved draft with sample data — draft component bodies inlined, persisted ones resolved through the owner cascade — and reports the findings. With a slug, tokens are checked against that template's rule surface and unresolved ones are errors; without one, against the base projection as warnings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…iour, declared in the registry (#102) * feat(email): slot/component grammar parser + render core + system lens 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 * docs(COMM-009): pin save-validator scope from adversarial render-slice 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 * feat(email): move unsubscribeUrl to the system lens 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 * feat(email): send-governance matrix — storage + save validation (COMM-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 * docs(COMM-010): lenses & composition model + recipient definition 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 * docs(COMM-010): set-valued polymorphic recipients + named lenses + conditional 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 * docs(COMM-010): recipient root is always User — reason outward via relations 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 * docs(COMM-010): dispatch, hydration boundary & collision (send path) 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 * feat(email): lenses block + lens-keyed matrix (COMM-010 slice 1b) 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 * docs(COMM-010): resume-here note — lens interpolation-surface is the 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 * docs(COMM-010/011): settle direction — code registry, one sender + one 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 * feat(email): simple typed registry — static recipient surface; drop DB 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 * feat(email): hydrated-payload cascade-diff decomposer (pure) 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> * feat(email): declarative registry via json-rules bindings (drop closures) 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> * feat(email): wire decompose into save; drop variant-indexing 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> * test(email): migrate sendEmail handler test to the declarative registry 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> * refactor(email): unify render into a single-pass settle() walker 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> * feat(email): {{#each}} loops on the single-pass settle() walker (COMM-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> * feat(email): {{#each}} save-time validation + straddle check (COMM-010) 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> * feat(email): validate component MJML fragments at save (COMM-009) 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> * fix(email): null-prototype safety in component-lookup maps 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> * feat(email): strict slot/component authoring gate at save (COMM-009) 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> * fix(email): defer duplicate_slot to component close, matching Zealot 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> * refactor(email): extract saveScopedRow, share the scoped find→mutate 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> * feat(email): reject divergent duplicate slugs at decompose (COMM-009) 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> * feat(email): add hydrate — the stored-form → editor-form inverse of decompose (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> * fix(email): bind resolution fails loud, and the inquiry entry picks the fields it binds A mis-declared or unthreaded bind path resolved to undefined, which json-rules turns into WHERE field = NULL: no recipients, no error, a silently dropped send. Every bind now goes through one fill that throws UnresolvedBindError naming the bind and path; the sender resolver uses the same fill instead of its own get. The inquiry entry bound sourceOrganizationId and targetUserId without picking them — it worked only because fetchLens returns raw rows. Both are now picked, and a registry invariant test pins that every entity-rooted bind names a picked field. Addresses the two open review findings on #74 (resolveEntry.ts:20, registry.ts:83). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * refactor(email): parseBlocks is the single grammar gate, expand is the single guarded walk Parity with Zealot's engine (ZLT-3271 5088a1360, #2112): - parseBlocks throws a typed ParseBlocksError (mismatched_close, stray_close, unclosed_open, invalid_slug, invalid_modifier, duplicate_slot) instead of popping the frame stack blind, so a malformed tag is rejected wherever the grammar is parsed — collectSlugs, decompose, hydrate, expand — not only behind a separate save-time gate. validateBlocks is folded in and deleted; the whole-body assertNoDuplicateExposedSlots (shadowing-aware) runs at the component save site. Addresses the open review finding at parseBlocks.ts:39. - expand owns the render walk: override scope chain ({ overrides, path, parent } — a fill renders in the scope that authored it, one hop up, so a slot re-exposed through a nested component's override still receives the grandparent's fill), render-time circular_ref guard on the path, one lookup per level and one parse per slug. renderBlocks was a second unguarded copy of the same walk and is deleted. expandWith takes the component loader so recompose can render pinned snapshot bodies through the same engine; expand binds it to the owner cascade. - hydrate round-trip test for the re-exposed slot: decompose(hydrate(row)) stays zero-write. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(email): {{#each}} parity with Zealot's post-review engine (ZLT-3326 #1689) - limits: 100 elements per loop, 2 levels of nesting. Over either cap the block sinks an error and renders nothing — never a silent truncation; save-time validation rejects the depth breach. - validateConditions desugars a leaf's binding-rooted field/path to its absolute path (m.tier inside {{#each recipient.memberships as=m}} → recipient.memberships.tier) before checkRuleAgainstLens, walks each logical leaf independently so one valid binding cannot suppress a sibling, and reports unknown binding roots per leaf. The lens rides in as options.lens; no caller passes one yet. - conditionParser owns RESERVED_SCOPE_ROOTS/TOKEN_PATTERN (settle's copies deleted) and reads whitespace-tolerant rule markers. - rules/walkConditionTree + rules/resolveBindingPath shared by validation. - Tests for every posture the review locked: bare binding token, non-primitive binding sinks, filter throw deduped, post-filter index, straddle, caps, desugar. Template keeps its four scope roots (sender, recipient, data, system): system is both the clock pre-pass namespace and the rail-provided bucket here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * refactor(email): errors and validators get their own folders; parseBlocks split into nodes, tags and validators - src/errors/: one file per error class (EmailRenderError, ParseBlocksError, ConditionValidationError, DivergentDuplicateSlugError, MjmlValidationError) - src/validations/: validateConditions, validateNoCycle, assertNoStrayTagShapes, assertNoDuplicateExposedSlots, assertNoDuplicateOverrideSlots join validateMjml - render/nodes.ts (node types + collectSlugsFromNodes) and render/blockTags.ts carry what parseBlocks used to own; expand prefetches through the shared walker - conditionParser: one parseRuleJson helper, no instanceof guard on JSON.parse - interpolate: drop the unused Lens enum (the four roots are RESERVED_SCOPE_ROOTS) - registry: rule literals instead of a cast eqBind helper - decompose/save: comment walls removed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * refactor(email): settle, conditionParser and validateConditions become folders of atomic files One exported function per file, index.ts barrels keep every import path working, and the last function declaration (settle) is an arrow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * refactor(email): bind each lens by name from the stage object in hand; drop the bind side tables Registry entries are lenses plus a sender pick. The entity lens binds from the handoff, the sender ids and recipient where pick off the entity row, cc/bcc off the recipient row. BindSources, EntitySpec, fill and UnresolvedBindError are gone; bindLens/bindWhere assert every required name is supplied and hand the engine a flat map. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP * fix(email): saves skip tombstones; scope roots typed once; orphan each-close is inert like if; comment walls cut - saveScopedRow matches only live rows, so re-saving a soft-deleted slug creates a fresh row instead of reviving the tombstone (test added) - SCOPE_ROOTS/ScopeRoot in the grammar; Variables derives from it - the render-time orphan {{/each}} sink is gone: save-time balance checks already reject it, so the two orphan closes now behave the same - explanatory comment walls removed from hydrate, compose, types, saveComponents Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP * INFRA-030: reference registry — the rows a rule names, as edges (design ticket) Template half of Zealot ZLT-4441 / #2116. False-polymorphic RuleReference on both axes (AuditLog's pattern, cascade on the referenced side), surface registry + after-hook writing edges in the save's transaction, staleness re-resolved on the referenced side when deletedAt flips, save gate + cycle check reading the registry. Extraction is a lens fact (ruleReferences in json-rules' lens module; this is its named first consumer). Email componentRefs stays slug-keyed — ruling recorded. Code lands with the first template surface (email conditions via INFRA-017/018); none exists today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FacfdVGsUptpixe731XXZQ * INFRA-030: RuleReference — the rows a rule names, as edges; email conditionals are the first surface RuleReference with false polymorphism on both axes (typed FKs, onDelete: Cascade, partial-unique edge identity, PolymorphismRegistry entries). Write hook recomputes an owner's edges in the save's transaction on every subject/mjml touch (set-diff; missing, soft-deleted, or path/bind-dynamic references are a 422). Staleness hook re-resolves every owner over the reverse edges when a referenced row's deletedAt flips, writing the degradedRuleRefs projection; composeTemplate unions template + expanded components and a branch naming a stale row is a rule error routed through onError — a stale rule is never evaluated. Extraction is the lens's: json-rules 2.20.0 ruleSourceValues over emailRuleNarrowing (recipient → User; tag/organization/space id sources); component componentRefs stay slug-keyed by ruling. 13 DB hook tests + 6 extraction + 2 render tests; email 110/110, api hooks+email 219/219, typecheck clean. Pins move to json-rules ^2.20.0 — bun.lock intentionally not regenerated until 2.20.0 is on npm (npm publish pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FacfdVGsUptpixe731XXZQ * INFRA-030: adversarial fix round — vocabulary closed, races fenced, projection keyed Three-agent findings, each fixed and pinned by a test. Extraction surface now equals the authoring surface: mapDefaults sources on each referenceable model's id (answer on every path), prismaMap-derived FK-column omits, and checkRuleAgainstLens run in the write hook — an FK spelling, typo path, or dotted-through-list reference is a 422, never a silently unregistered rule; an undeclared relation path to a referenceable id registers. Races fenced with db.findForUpdate (extended to { id: { in } }): the save gate locks referenced rows before liveness, reresolveDegraded locks owners before computing. The gate validates the delta (pre-existing dead refs stay editable and flagged; only new ones refuse); archived owners keep their projection maintained via db.withDeleted; degradedRuleRefs holds Model|id keys and defaults to [] at the DB. Render evaluates over the nested {sender, recipient, data} object; dynamic rules are a rule error unconditionally; unterminated {{#if}} blocks are reported and suppressed instead of shipping raw rule JSON; a malformed nested marker can no longer bisect the outer block via a {{/if}} inside a JSON string. Hook tests run the full prod hook set (scoper + preventHardDelete + rules) and reset the scoper after. Suites: email 119/119, api hooks+email 225/225, typecheck clean on db/email/api. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FacfdVGsUptpixe731XXZQ * INFRA-030: pin the clear-on-fix invariant — a save that removes a dead reference clears flag and edge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FacfdVGsUptpixe731XXZQ * INFRA-030: staleness is asked, not stored — drop the projection and its hook "Which of my references are gone" is a question the referenced rows already answer. Storing the answer bought nothing and cost a column on every owner, a hook on every referenced model, a re-resolve walk, a lock, and an invariant that could drift from the rows it described. composeTemplate now resolves the references of the content it just expanded in one query and passes the LIVE keys down. A branch naming a row outside that set is a rule error, never a match. Absence is the answer, so never created, soft deleted, and hard deleted with the edge cascaded away all fail closed without the render telling them apart — and the archived-owner and purge-ordering cases stop existing rather than being handled. Gone: degradedRuleRefs on EmailTemplate/EmailComponent, ruleReference/degraded.ts, reresolveDegraded, writeDegraded, degradedFrom, hydrated, collectDegradedRuleRefs and its cascade walk. Kept: the edge table, for the two questions a row cannot answer about itself — who references X, and may this save name that row. The delta gate keeps its findForUpdate fence, which is now the only lock in the feature. findForUpdate: an empty in-list locks nothing instead of throwing, and a predicate naming a field the map does not know is refused rather than interpolated into SQL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq * INFRA-030: the edge outlives its target — true poly beside the false, deletedAt denormalised Staleness is two signals on the edge row, not a computed flag and not a deep include. An edge has to survive the row it names. The referenced FK loses its onDelete, so a purge SET NULLs it rather than deleting the evidence, and the axis gains `referencedId` — true polymorphism beside the false — so the edge still names the row that went. The two are clocks on one fact: the FK is the relation, owned by referential integrity, and goes null the moment the row ceases to exist; `referencedId` is the name, owned by the rule content, written once. They agree while the target lives and diverge exactly where it matters. PolymorphismRegistry learns `idField` and keeps them in step at write time, admitting the all-FKs-null branch as legal — reachable only through a referential action, never through a write. That axis shape also covers a bare pair and a typed-FK-only subject, so AuditLog and Token stop being different kinds of thing from this table. Soft delete is the other signal: ruleReference:referenced copies the target's deletedAt onto the edges naming it, one updateManyAndReturn per model, matched on the true-poly pair so a purged edge is never rewritten. No walk, no closure, no transitive flag — one scalar across one hop, which can be stale but never subtly wrong. The payoff is the read. ruleReferenceIssues(edges) is pure and takes no relations, so a consumer writes `include: { ruleReferences: true }` and never grows that include as models become referenceable. composeTemplate reads the template's edges plus those of the components the cascade actually resolved — `expand` now returns their ids — instead of re-parsing the composed content. Also: REFERENCED_MODELS comes from the lens instead of the FK map. Deriving what a rule may name from which columns happen to exist let storage grant and revoke vocabulary silently; axisKey already fails loudly when the schema has not caught up, which is the right direction. Six partial uniques collapse to two now the discriminator is inside the key. The lens-as-data question is written into the ticket as an open item, not answered here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq * INFRA-030: drop the id/FK agreement invariant — one writer sets both syncRuleReferences writes referencedId and the typed FK from the same value on the same line, so a registry-driven rule enforcing that they match guards a one-line assignment. The PolymorphismRegistry axis loses idField and toRules goes back to shape only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq * INFRA-030: edges come from the save path, not a hook The only hook left is on the referenced side. Everything the owner-side hook did — sniff args for the rule column, guess the upsert arm, key a surfaces registry by model — is gone; syncRuleReferences(owner, contents, lens) is a service in packages/email/src/rules, and saveEmailTemplate calls it inside its transaction for the template and each component it saved. That is the only writer of mjml/subject in the repo, so there is nothing for a hook to catch that the call does not. Deleted: hooks/ruleReference/hook.ts, surfaces.ts, sync.ts, touchesSurface, RULE_REFERENCE_SURFACES, REFERENCED_MODELS. The referenced hook reads the referenceable set from the lens and the FK column from the registry directly. The gate throws RuleReferenceError — a sibling of ConditionValidationError on the same path — because packages/email has no hono dependency and makeError lives in the api. Two things the move surfaced. The stored template body keeps each component block inline, so its own references are the ones left once those blocks are emptied; cleanRefs now accepts the stored (untagged) form as well as the tagged intermediate, and the template is synced against cleanRefs(mjml). And packages/email's own save test used recipient.role in a fixture — a path the lens never resolved — which the api-side hook had let through because the gate did not run there; the fixture names recipient.email now. Tests go through saveEmailTemplate rather than the factories, since factories write rows without edges by design. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq * INFRA-030: the service reads through the lens it is handed syncRuleReferences took a lens and ignored it — ruleReferences, contentRuleReferences, ruleVocabularyIssues and contentVocabularyIssues all closed over emailRuleNarrowing. Now the lens is the first argument of each, the memo is keyed per lens (the same rule read through a different lens names different rows), and the render path passes the base lens explicitly. This is the seam the parked "lens that lives in a row" item needs; nothing about behaviour changes while there is one lens. Also: the schema header still credited the deleted surfaces registry, the ticket still said "write hook" and "422" for a gate no route calls, and the test file was named for a module that no longer exists — hook.test.ts is ruleReference.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq * INFRA-030: format Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq * INFRA-030: rebase onto main, json-rules 2.21.1 everywhere Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014LrHJDW6ybsP8VyX26ioxz * INFRA-030: withRule — the one fork a stored rule is evaluated through A stored rule degrades two ways: the lens stops admitting it, or a row it names is gone. Both are asked at evaluation, against the current lens and the caller's live set, in one pure helper in @template/shared/rules with two arms — degraded (do nothing new, say why) and sound (evaluate). The email renderer is the first consumer; Zealot's sweep, match filters and auto-approval port onto the same call. An omitted live set now fails closed: nothing confirmed means every named row is missing. The renderer tests move onto lens-admitted `data.*` paths, since a made-up recipient column is now a vocabulary violation at evaluation, as it already was at save. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014LrHJDW6ybsP8VyX26ioxz * INFRA-030: two failure paths — bindings and gone data; the dynamic arm is retired withRule asks two questions: is every required binding supplied, and is the rule still valid (lens admits it, every named row live). A value read through path or bind names no row: no edge, no refusal. Ruling 2026-09-10. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * chore: json-rules ^2.22.0 — bindOptional Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * chore: json-rules ^2.22.0 — bindOptional Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * test(email): an optional bind the stage object lacks is not required Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(email): derive a plain-text part and sanitize the subject at send time Sends were HTML-only and the subject reached the provider raw. Interpolation always HTML-escapes substituted values, so a recipient named O'Brien produced O'Brien in the Subject header, and nothing stripped CR/LF from substituted values — the header-injection vector for a value carrying a newline. A missing text/plain part also costs spam score and accessibility. sanitizeSubject reverses the interpolation escaping (lodash unescape mirrors the escape applied), replaces control characters with spaces, and collapses whitespace. deriveTextFromHtml derives the multipart alternative from the rendered MJML output: drops head/style/script, maps block boundaries and <br> to newlines, keeps link targets next to their labels, decodes entities, and collapses blank-line runs. deliverEmail applies both where the payload is handed to the client; SendEmailOptions and the Resend payload carry the new text part. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * feat(email): capture the settled mjml and variables on CommunicationLog The send pin (emailTemplateAuditLogId) preserves the raw stored template at its sent version, but nothing preserved what actually went out: expand resolves components through the sender's cascade at send time, interpolate fills the lens variables, and both outputs lived only in job memory. A component edit or a different sender scope made the sent email unreconstructable. Persist both at the sending claim: settledMjml — the post-expand, post-interpolate MJML handed to mjml2html, which embeds the send-time component resolution — and variables, the resolved lens payload (sender/recipient/data plus the per-kind system vars), now returned on SettledTemplate. The raw template body is deliberately not duplicated here: the pinned audit row's own snapshot already carries it. Rendered HTML is likewise derivable from settledMjml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * fix(email): recompose snapshots through the slot engine, not block substitution recomposeSnapshot substituted child snapshots with a regex that replaced the entire component ref span — including the caller's override slots, which the slot grammar deliberately keeps inline on the ref. Since the slot engine landed, that meant recompose dropped every override and rendered defaults, and a cyclic snapshot graph recursed without bound. Rebuild it on renderBlocks: walk the pinned componentVersions graph breadth first collecting each slug's snapshot body (nearest pin wins, visited set bounds cycles), then render the root snapshot with those bodies as the loader — override injection, empty-fill semantics, and the render cycle guard all come from the engine instead of a parallel substitution path. The pure core (recomposeFromSnapshots) takes an injected snapshot loader so it is testable without a database; the db-bound wrappers stay thin. A dangling or cyclic pin now renders empty instead of leaking raw grammar or hanging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * fix(email): attribute-aware tag stripping and fuller entity decoding in deriveTextFromHtml The tag pattern stopped at the first > inside a quoted attribute, so <img alt="a > b"> leaked b"> into the plaintext part, and only a minimal entity set decoded, so ©/©/😀 shipped as literal source in text while the HTML rendered them. Match tag bodies with quoted-attribute awareness everywhere a tag is consumed, contribute an image's alt text instead of dropping it, and decode numeric, hex, and the common named entities (amp still last and skipped by the named pass, so double-encoded input stays literal). Mirrors the review fix in Zealot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * fix(email): consume comments, doctypes, and lenient tag forms in deriveTextFromHtml HTML comments and doctype declarations leaked verbatim into the text part, a closing </style > with whitespace escaped DROPPED_SECTIONS so stylesheet text shipped as content, and href extraction required quotes with no attribute-name boundary — an unquoted href lost its destination and a data-href appearing first hijacked the link target. Consume comments and doctypes as markup, allow whitespace before > on closing tags this helper matches, and extract href/alt with an attribute-name boundary accepting quoted or unquoted values. Mirrors the review fixes in Zealot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * fix(email): replay a sent communication from settledMjml, not the save-time pin recomposeCommunication reconstructed sent mail from the pinned template snapshot even when the row carried the recorded settledMjml. The pin is a save-time reconstruction resolved through the template row's own owner scope, while the send resolved components through the sender's cascade — so for any sender-tier component override the pin replay returned a different body than the one that shipped. Prefer the recorded settledMjml (the sent truth) and fall back to the pinned snapshot only for rows sent before capture existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * fix(email): keep visible layout boundaries in the derived text part Adjacent table cells concatenated and a link label containing block elements flattened to one line. Treat td/th closers as block boundaries and make renderLink apply the block pass itself, normalizing only spaces and tabs. Mirrors the review fixes in Zealot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * fix(email): compare link label and target decoded before deduplicating renderLink printed the URL twice when the label and href differed only by entity encoding, because the no-repeat comparison ran on the raw strings while decoding happens after link rendering. Compare decoded values. Mirrors the review fix in Zealot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * Replace hand-rolled deriveTextFromHtml with html-to-text Parses with htmlparser2 instead of regexes, covering the attribute and entity edge cases the regex version handled case by case plus the long tail it did not (CDATA, script variants, nested quoting). The wrapper keeps the same contract: block boundaries as newlines, alt text for images, label (href) links with same-target dedupe, nbsp and blank runs normalized, and unbounded dataTable columns so MJML layout tables never rewrap body copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * Pin the send-time component version closure on CommunicationLog settledMjml records the document a send actually shipped, but nothing recorded it structurally: which component row each slug resolved to through the sender's cascade, at which audit version. The save-time componentVersions pin cannot carry this — it resolves through the template owner's scope, and one template row serves many senders. CommunicationComponentVersion is that record: one row per (send, slug), pointing at the resolved EmailComponent and its latest audit snapshot, written inside the sending-claim transaction. expand surfaces the resolutions it already performs through an optional onResolve sink; composeTemplate returns them as componentResolutions; a re-claimed retry rewrites the closure rather than duplicating it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF * test(email): CommunicationLog factory; a Space-sender log carries no organization id The closure test created its log directly with senderType Space plus senderOrganizationId, which the CommunicationLog polymorphism axis forbids (Space → senderSpaceId only). The rules hook only enforces that when an earlier test file has registered it, so the test passed alone and failed in the full run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * refactor(email): one owner table, one lookupAtOwner lookup.ts was the same query written seven times, once per owner tier, and the cascade order was hand-written three more times across lookupTemplate, lookupCascade, and compose.parentOwner. owner.ts now holds the chain (parentOwner, ownerCascade) and the per-tier where (ownerWhere — the Space→Organization edge carries inheritToSpaces), and lookupAtOwner takes the tier. 368 lines become 161; the per-tier predicates are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * chore(email): rules-builder 0.27.0 for the headless authoring hooks; mjml-preset-core for the nesting table Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(db): EmailTemplate.lens and EmailComponent.expectations The authoring lens lives on the slug's default-tier row as per-slot narrowings over the projection the system provides; tenant rows inherit it through the cascade. A component's expectations are the absolute paths its body demands, derived at save and never authored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(email): the projection the system provides, the lens the row holds emailProjection composes a synthetic-root lens (EmailRuleContext → recipient, sender, data) over the whole field map: what is reachable for a template, with no narrowing of its own. emailLens applies the row's per-slot narrowings (engine defaults where the row is silent: recipient = the delivery leaf, sender and data = their scalars) and emailSurface exposes the result for the builder. emailRuleDecoration derives one facet per root relation. Ported from Zealot's rules layer (#1689, #1655, #2171): collectHydrationPaths, walkLensPath, componentExpectations (system.unsubscribeUrl is the rail-provided field here, not a recipient field), collectJsonOpacityWarnings. Component save derives expectations from the body. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(email): headless authoring surface — regions, MJML nesting table, authoring barrel Ported from Zealot (#1698, #2090): collectComponentRegions, collapseComponentBodies, removeSlotOverride, slotDefaultContent, canNestMjml/MJML_CHILD_TAGS, and the authoring barrel the editor consumes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(api): admin emailTemplate ruleSurface — the authoring surface for a slug POST /api/admin/emailTemplate/ruleSurface returns the projection for the slug (sender model and data shape from the registry entry; recipient-only for a slug the registry does not know) narrowed by the lens on the slug's default-tier row, plus the decoration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(ui): headless email authoring hooks useEmailRuleSurface fetches a slug's surface. useEmailVariableScope resolves it through rules-builder and walks it as a scope stack: values are copyable tokens, to-many relations are loop portals; entering one re-anchors the scope at the element model and wraps every value copied inside in the enclosing {{#each}} blocks with collision-free kebab bindings. No components — the forms come later. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * docs(COMM-012): adhoc templates — the projection the system provides, the lens the row holds Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * fix(email): data is unknown by construction — an opaque Json root, addressable at any depth The projection had bound data to a declared shape, which is not what data is: the per-template payload nobody can declare ahead of the event. Its root is now a Json field — every {{data.…}} path and every rule beneath it is addressable, and validation reports it as beneath Json (a warning), never missing. A registry entry may still overlay a declared shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * feat(email): the data slot is a lens too — the row chooses its entry point and relations Each slot on the row is its own lens built from the projection: recipient's entry point is User, sender's is the sender model, and data's is whatever the author chooses (lens.data.model) with relations beneath it (lens.data.narrowing). No entry point = the unknown bag. COMM-012 records the perspective question and the palette/shell plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * chore(email): follow the #74 restructure — errors, nodes and tags import from their new homes; registry entries are lenses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP * docs(COMM-013): email errors and degraded rule behaviour — problem statement Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP * feat(email): deliverability preflight — content checks and a draft preflight action (port of Zealot #2171) Content checks over a rendered draft, network-free and observation only: subject, preheader, unsubscribe link, image alt text, tokens the lens cannot resolve, render warnings and spam-trigger phrases. Each check is `(input) => PreflightFinding[]`, injectable into `runPreflight`, which settles them all and orders findings errors-first with counts. `POST /api/admin/emailTemplate/preflight` renders an unsaved draft with sample data — draft component bodies inlined, persisted ones resolved through the owner cascade — and reports the findings. With a slug, tokens are checked against that template's rule surface and unresolved ones are errors; without one, against the base projection as warnings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * COMM-013: save decides what the lens can decide; render has one behaviour, declared in the registry Save: validateTokens on the expanded body and subject against the slug's lens (unknown mustache, root, path; reads through scalars or lists; objects; optional paths must sit under a positive presence guard), components judged through the template that embeds them, a component save re-validates same-owner dependents (validateDependents). Render: RenderIssue sink, no literal token ever ships, each with a throwing filter renders nothing, rules judged through withRule against the slug's lens with loop bindings resolved to absolute paths. settleTemplate reads render: { onIssue, substitute } from the registry entry (fail default, degrade, substitute); subject issues and a missing unsubscribe contact are fatal; issues are stored on CommunicationLog.renderIssues. Removed: EmailErrorPolicy + EmailTemplate.onError, EMAIL_INLINE_RENDER_ERRORS, the fallback tier walk. Planner throws on a missing entry, adapter, or declared data field. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * chore(email): token validator reports a message, the caller records it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * fix(email): a missing adapter is an environment gap, not a planning failure; bridge tests name a registered template Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * COMM-013: review fixes — loop-bound rules judged as absolute paths at save and render, each filters through withRule, unterminated if sinks and renders nothing, Json-root guard covers nothing beneath it, scalar lists iterate, dependents walk inherited components, save lens from the merged row, substitute keeps the primary's kind Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP * merge cleanup: the squashed stack is content-identical, the errors branch wins on every file it touched Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP --------- Co-authored-by: Claude <noreply@anthropic.com>
Update (2026-09-08) — ready for review
Main is merged in (169 commits, four conflicts in the render package resolved), Steven's three open findings are addressed, and the engine is at parity with Zealot's post-July state. Four commits on top of the merge:
{{system.now}}/{{system.year}}tokens ride the settle()-basedinterpolateas a pre-pass (clock-resolved, non-overridable, before conditionals and substitution); the caller-suppliedsystembucket stays for rail-provided values (system.unsubscribeUrl). Both prototype-keylookupCascadetests kept.wherepick off the entity row, cc/bcc off the recipient row.bindLens/bindWhereask the engine (lensRequiredBindings/requiredBindings) which names a lens needs, assert each is a key of the map, and hand the engine a flat map; a missing name throws, a present null passes through and thewherefails closed. The bind side tables (BindSources,EntitySpec,fill,UnresolvedBindError,resolveEntry.ts) are gone. The inquiry entry pickssourceOrganizationId/targetUserId, and the registry invariant test uses the engine's ownrequiredBindingsto pin that every recipient bind and sender id is a field the entity picks.parseBlocksthrows a typedParseBlocksError(mismatched_close | stray_close | unclosed_open | invalid_slug | invalid_modifier | duplicate_slot) wherever the grammar is parsed;validateBlocksis folded in and deleted; the whole-bodyassertNoDuplicateExposedSlotsruns at the component save.expandowns the render walk with the override scope chain (a fill renders in the scope that authored it, so a slot re-exposed through a nested component's override still receives the grandparent's fill), the render-timecircular_refguard, one lookup per level and one parse per slug.renderBlocks(a second unguarded copy of the walk) is deleted.expandWith(mjml, lookup)takes the loader so recompose renders pinned snapshots through the same engine. This absorbs feat(email): concentric slot pass-through — fill a slot re-exposed through a nested component #91's first three commits; feat(email): concentric slot pass-through — fill a slot re-exposed through a nested component #91 is now stacked on this as its template-specific remainder.{{#each}}parity with Zealot #1689 post-review —limits.ts(100 elements, depth 2; over either cap the block sinks and renders nothing);validateConditionsdesugars a leaf's binding-rootedfield/pathto the absolute path beforecheckRuleAgainstLens, walks each logical leaf independently, reports unknown binding roots per leaf; whitespace-tolerant rule markers;rules/walkConditionTree+rules/resolveBindingPath. Template keeps four scope roots (sender | recipient | data | system).Verified:
packages/email347/347;apps/api1021 pass, 2 fail — the lens count-scope tests that #95 fixes on main. Typecheck clean on db/email/api; biome clean.Deliberately not ported (Zealot has them, they are follow-ups, not this PR):
regions.ts/authoring.tsbuilder decorations (#1698),mjmlNesting.ts(#2090, needs the editor),preflight/content checks (#2171),rules/buildEmailRuleLens+componentExpectations(#1655, needs a lens builder on this side), JSON-opacity save warnings (no warnings channel in save yet),EACH_HYDRATION_TAKE(no hydration consumer yet).Open questions for the architect (from the port):
validateConditionstakes the lens asoptions.lensrather than Zealot's positional argument, to keep the save callers untouched. No caller passes a lens yet.systemas a scope root, asystem.unsubscribeUrlrule is lens-checked like any other root, so a future lens builder must modelSystem. Zealot treatssystemas pre-pass only.{{#each}}in subjects at render time as defense-in-depth; here only the save-time ban exists.ParseBlocksErrorhas no HTTP mapping because none of its siblings (MjmlValidationError,ConditionValidationError,DivergentDuplicateSlugError) do either — there is no email save endpoint in this repo yet.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 theparseBlocksAST; per component ref, partitions caller overrides (kept inline, caller-owned) from the component's own body (chrome +:defaultslots), 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). ReplacesmapRefs/resolveVariantsvariant-indexing — noslug: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).EmailEntryis serializable data — closures gone: entity/recipientwherecarry{bind}tokens whose names are fields of the stage object that binds them; sender is a typed pick of entity fields;datais the list of handoff fields forwarded to the render scope. (Superseded on 2026-09-10 by item 2 above: the bind side tables that first shipped here are gone.)decomposewired intosave.ts(collectSlugs+decompose).saveEmailTemplatenow 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) — noslug:idxvariants, no fork-suffixes.save.test.tsrewritten to the noop/shadow/no-variant model (+ explicit org-shadow coverage). The supersededextractRefs(mapRefs) +resolveVariantsmodules were removed.Fixed a pre-existing
enqueue ↔ handlersimport cycle (onmain, backmerged here). Jobs re-enqueue jobs, so handlers importenqueueJoband the registry imports every handler —enqueue's static registry import closed an eval-time loop that TDZ-threw whenever a single handler module loaded beforehandlers/index.ts(e.g. a handler's own test).enqueuenow lazy-loads the registry at its single call site (JobPayloadsstays a static type). This unblockedsendEmail.test.ts, which was previously unrunnable.Migrated
sendEmail.test.tsto the declarative registry — now 7/7, exercising the bindings resolver end-to-end through the planner + DB.Verified: full email render suite green + api email units (registry + bind + 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/constructorguard + reservedsystemlens;unsubscribeUrl→system.Related
Review pass (2026-09-10)
Aron's review notes, all landed: errors live one-per-file in
src/errors/; validators insrc/validations/(validateConditions, validateNoCycle, the stray-tag and duplicate-slot asserts that used to sit inside parseBlocks);settle,conditionParserandvalidateConditionsare folders of one-function files;parseBlocksis only the parser, with node types inrender/nodes.tsand tag grammar inrender/blockTags.ts; the unusedLensenum is gone (the four roots areSCOPE_ROOTS/ScopeRootin the grammar andVariablesderives from it); expand prefetches through the shared slug walker; the JSON-parse catch is one helper; comment walls cut;saveScopedRowskips tombstones (test); the render-time orphan{{/each}}sink is gone since save-time balance checks already reject it.Parked until the stack is reviewed: the render error strategy (sink vs throw, the inline-errors env flag, the bare Error in save), the one-template-per-pair ruling into COMM-012.