Skip to content

COMM-013: save decides what the lens can decide; render has one behaviour, declared in the registry - #102

Merged
agreenspan merged 82 commits into
mainfrom
feat/email-errors
Sep 10, 2026
Merged

agreenspan merged 82 commits into
mainfrom
feat/email-errors

Conversation

@agreenspan

Copy link
Copy Markdown
Contributor

Stacked on #97. Carries a merge of #92 (INFRA-030) re-implemented on the #74 layout, because the error strategy needs the RuleReference edges and #92's email parts were written against the old file layout: collectRules lives in conditionParser/, withRule runs in settle/settleBranches (loop bindings resolved to absolute paths via absoluteRule before the lens judges them), compose returns liveRuleRefs.

The ruling (COMM-013, 2026-09-10)

Save refuses everything the lens can decide. validateTokens runs on the expanded body and the subject against the slug's rule surface: unknown mustache, unknown root, path the lens lacks, read through a scalar or a list without {{#each}}, object where a value is expected, unknown system token. A token on an optional path (nullable field or relation, anything beneath Json) must sit under an {{#if}} whose rule conjoins a positive presence leaf on that path or its nullable prefix. Negated leaves, any groups and {{else}} guard nothing. guardedToken(path, fallback) emits the guarded form for the builder.

Component ↔ template checks propagate both ways. A template judges the components it embeds through its own lens. A component save re-validates every same-owner template that embeds it (transitively) against that template's lens and refuses with the list (DependentTemplateError). Other owners are stamped by the versioning hook, never refused.

No raw token ever ships. Nil, object, unknown root, prototype key, unknown system token: empty plus a typed RenderIssue.

One render behaviour, declared in the registry entry. EmailErrorPolicy, EmailTemplate.onError, EMAIL_INLINE_RENDER_ERRORS and the fallback tier walk are gone. settleTemplate reads render: { onIssue, substitute } from apps/api/src/lib/email/registry.ts: fail (default) throws and the job retries to DLQ; degrade sends with issues stored on CommunicationLog.renderIssues; substitute names a stable template rendered instead, with the primary's variables, and it must render clean. Subject issues and a missing unsubscribe contact are always fatal. An {{#each}} whose filter throws renders nothing.

Planning failures are loud. sendEmail throws on a missing registry entry, a missing adapter, or a declared data field the event did not supply.

An adversarial pass on the plan (before build) moved five things; they are recorded in tickets/COMM-013-email-errors-and-degraded-rules.md.

Verification

bun run typecheck clean except the pre-existing @template/sdk stale barrel. packages/email 466, packages/db 293, packages/shared 136, api email/ruleReference/emailVersioning/emailTemplate/sendEmail/deliverEmail suites 90, CI rules pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP

claude and others added 30 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>
Collapse the two-pass render (evaluateConditions → trailing global
VARIABLE_PATTERN replace) into one recursive walker. `settle(content,
scope, { substitute })` handles {{#if}} branches and token substitution
in a single pass — substitute:false is evaluateConditions, substitute:true
is interpolate, both now thin wrappers. A substituted value is emitted at
its own scope depth and never re-scanned.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carries main's reserved {{system.now}}/{{system.year}} tokens onto the settle()-based
interpolate as a pre-pass (non-overridable, before conditionals and substitution), keeps
the caller-supplied system bucket for rail-provided values, and merges both
prototype-key lookupCascade tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…he fields it binds

A mis-declared or unthreaded bind path resolved to undefined, which json-rules turns into
WHERE field = NULL: no recipients, no error, a silently dropped send. Every bind now goes
through one fill that throws UnresolvedBindError naming the bind and path; the sender
resolver uses the same fill instead of its own get.

The inquiry entry bound sourceOrganizationId and targetUserId without picking them — it
worked only because fetchLens returns raw rows. Both are now picked, and a registry
invariant test pins that every entity-rooted bind names a picked field.

Addresses the two open review findings on #74 (resolveEntry.ts:20, registry.ts:83).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…e single guarded walk

Parity with Zealot's engine (ZLT-3271 5088a1360, #2112):

- parseBlocks throws a typed ParseBlocksError (mismatched_close, stray_close,
  unclosed_open, invalid_slug, invalid_modifier, duplicate_slot) instead of popping the
  frame stack blind, so a malformed tag is rejected wherever the grammar is parsed —
  collectSlugs, decompose, hydrate, expand — not only behind a separate save-time gate.
  validateBlocks is folded in and deleted; the whole-body assertNoDuplicateExposedSlots
  (shadowing-aware) runs at the component save site. Addresses the open review finding
  at parseBlocks.ts:39.
- expand owns the render walk: override scope chain ({ overrides, path, parent } — a fill
  renders in the scope that authored it, one hop up, so a slot re-exposed through a nested
  component's override still receives the grandparent's fill), render-time circular_ref
  guard on the path, one lookup per level and one parse per slug. renderBlocks was a
  second unguarded copy of the same walk and is deleted. expandWith takes the component
  loader so recompose can render pinned snapshot bodies through the same engine; expand
  binds it to the owner cascade.
- hydrate round-trip test for the re-exposed slot: decompose(hydrate(row)) stays zero-write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
claude and others added 20 commits September 10, 2026 11:37
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
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
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
…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
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
…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
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
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
…, 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
…r 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
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
… the lens the row holds

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…dressable 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
…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
…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
…eflight 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
…iour, 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
…ailure; bridge tests name a registered template

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
@agreenspan
agreenspan force-pushed the feat/email-authoring-headless branch from bb2e1c6 to 1c7144b Compare September 10, 2026 16:11
agreenspan and others added 3 commits September 10, 2026 13:15
… 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
# Conflicts:
#	apps/api/src/jobs/handlers/deliverEmail.ts
#	apps/api/src/lib/email/registry.ts
#	apps/api/src/lib/emailTemplate.ts
#	apps/api/src/modules/emailTemplate/services/emailTemplatePreflight.ts
#	apps/api/src/modules/emailTemplate/services/emailTemplateRuleSurface.ts
#	packages/db/prisma/schema/communicationLog.prisma
#	packages/email/src/errors/EmailRenderError.ts
#	packages/email/src/errors/index.ts
#	packages/email/src/render/authoring.ts
#	packages/email/src/render/compose.ts
#	packages/email/src/render/conditionParser/index.ts
#	packages/email/src/render/eachLoops.test.ts
#	packages/email/src/render/evaluateConditions.ts
#	packages/email/src/render/index.ts
#	packages/email/src/render/interpolate.test.ts
#	packages/email/src/render/interpolate.ts
#	packages/email/src/render/save.test.ts
#	packages/email/src/render/save.ts
#	packages/email/src/render/settle/index.ts
#	packages/email/src/render/settle/settle.ts
#	packages/email/src/render/settle/settleBranches.ts
#	packages/email/src/render/settle/settleEach.ts
#	packages/email/src/render/settle/substituteToken.ts
#	packages/email/src/render/settle/types.ts
#	packages/email/src/rules/index.ts
#	packages/email/src/validations/index.ts
#	tickets/COMM-013-email-errors-and-degraded-rules.md
…anch wins on every file it touched

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
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.

2 participants