Skip to content

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

Merged
agreenspan merged 37 commits into
mainfrom
claude/email-interpolation-slots-pnnkti
Sep 10, 2026
Merged

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

Conversation

@agreenspan

@agreenspan agreenspan commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Merge origin/main — main's reserved {{system.now}}/{{system.year}} tokens ride the settle()-based interpolate as a pre-pass (clock-resolved, non-overridable, before conditionals and substitution); the caller-supplied system bucket stays for rail-provided values (system.unsubscribeUrl). Both prototype-key lookupCascade tests kept.
  2. Each lens binds by name from the stage object in hand (findings OpenTelemetry in API #2 and Add FEAT-013 and FEAT-014 tickets, update feature matrix #3) — a registry entry is lenses plus a sender pick: the entity lens binds from the handoff, the sender ids and the recipient where pick off the entity row, cc/bcc off the recipient row. bindLens/bindWhere ask 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 the where fails closed. The bind side tables (BindSources, EntitySpec, fill, UnresolvedBindError, resolveEntry.ts) are gone. The inquiry entry picks sourceOrganizationId/targetUserId, and the registry invariant test uses the engine's own requiredBindings to pin that every recipient bind and sender id is a field the entity picks.
  3. parseBlocks is the single grammar gate, expand is the single guarded walk (finding Claude/refactor polymorphism hook #1, Zealot 5088a1360 / #2112) — parseBlocks throws a typed ParseBlocksError (mismatched_close | stray_close | unclosed_open | invalid_slug | invalid_modifier | duplicate_slot) wherever the grammar is parsed; validateBlocks is folded in and deleted; the whole-body assertNoDuplicateExposedSlots runs at the component save. expand owns 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-time circular_ref guard, 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.
  4. {{#each}} parity with Zealot #1689 post-reviewlimits.ts (100 elements, depth 2; over either cap the block sinks and renders nothing); validateConditions desugars a leaf's binding-rooted field/path to the absolute path before checkRuleAgainstLens, 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/email 347/347; apps/api 1021 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.ts builder 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):

  • validateConditions takes the lens as options.lens rather than Zealot's positional argument, to keep the save callers untouched. No caller passes a lens yet.
  • With system as a scope root, a system.unsubscribeUrl rule is lens-checked like any other root, so a future lens builder must model System. Zealot treats system as pre-pass only.
  • Zealot also bans {{#each}} in subjects at render time as defense-in-depth; here only the save-time ban exists.
  • ParseBlocksError has 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 the parseBlocks AST; per component ref, partitions caller overrides (kept inline, caller-owned) from the component's own body (chrome + :default slots), diffs the body against an injected cascade resolver → noop/inherit (unchanged) vs write (new/diverged), attributing nested refs by region (ref in a :default → owned by the enclosing component; ref in an override → bubbles to the caller). Replaces mapRefs/resolveVariants variant-indexing — no slug:idx, no fork-suffixing; fork = a new slug (an FE rename). Covers the parent-ships-child-pre-filled nesting regression + child-first write ordering.

  • Declarative registry via json-rules bindings (apps/api/src/lib/email/registry.ts). EmailEntry is serializable data — closures gone: entity/recipient where carry {bind} tokens whose names are fields of the stage object that binds them; sender is a typed pick of entity fields; data is 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.)

  • decompose wired into save.ts (collectSlugs + decompose). saveEmailTemplate now decomposes the hydrated payload against the owner cascade: an inlined body equal to the resolved cascade body is a noop/inherit (no write); a divergence (or unknown slug) writes the same slug at the current tier (shadow) — no slug:idx variants, no fork-suffixes. save.test.ts rewritten to the noop/shadow/no-variant model (+ explicit org-shadow coverage). The superseded extractRefs (mapRefs) + resolveVariants modules were removed.

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

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

Verified: full email render suite green + 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/constructor guard + reserved system lens; unsubscribeUrlsystem.

Related

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

Review pass (2026-09-10)

Aron's review notes, all landed: errors live one-per-file in src/errors/; validators in src/validations/ (validateConditions, validateNoCycle, the stray-tag and duplicate-slot asserts that used to sit inside parseBlocks); settle, conditionParser and validateConditions are folders of one-function files; parseBlocks is only the parser, with node types in render/nodes.ts and tag grammar in render/blockTags.ts; the unused Lens enum is gone (the four roots are SCOPE_ROOTS/ScopeRoot in the grammar and Variables derives from it); expand prefetches through the shared slug walker; the JSON-parse catch is one helper; comment walls cut; saveScopedRow skips 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@stevenolay stevenolay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

Comment thread apps/api/src/lib/email/resolveEntry.ts Outdated

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Comment thread apps/api/src/lib/email/registry.ts Outdated
}),
bindings: { inquiryId: 'data.inquiryId' },
},
sender: { type: 'Organization', bindings: { organizationId: 'entity.sourceOrganizationId' } },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

@stevenolay

Copy link
Copy Markdown
Contributor

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

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

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

Three open findings, details inline:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
@agreenspan
agreenspan marked this pull request as ready for review September 9, 2026 01:00
agreenspan and others added 3 commits September 8, 2026 22:10
…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
agreenspan and others added 4 commits September 9, 2026 20:09
…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
agreenspan added a commit that referenced this pull request Sep 10, 2026
…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
agreenspan added a commit that referenced this pull request Sep 10, 2026
…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
agreenspan and others added 2 commits September 10, 2026 11:35
agreenspan added a commit that referenced this pull request Sep 10, 2026
…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
@agreenspan
agreenspan merged commit 941b5a0 into main Sep 10, 2026
agreenspan added a commit that referenced this pull request Sep 10, 2026
…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
agreenspan added a commit that referenced this pull request Sep 10, 2026
…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>
agreenspan added a commit that referenced this pull request Sep 10, 2026
…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&#39;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 &copy;/&#169;/&#x1F600; 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants