COMM-013: save decides what the lens can decide; render has one behaviour, declared in the registry - #102
Merged
Conversation
COMM-009 foundation, TDD. Adds the pinned slot grammar and the render path
that consumes it; interpolation gains a `system` lens.
- parseBlocks: pure DB-free parser, tokenizes {{#component}}, {{#slot}},
{{#slot:name:default}} into a text/component/slot node tree. Syntax only —
ownership (override vs injection) is decided by consumers. Interpolation and
{{#if}} stay opaque.
- renderBlocks: pure render core with an injected component-body loader. Per
ref: collect caller override slots, load body, inject override at each slot
marker else render :default, recursing. Empty default holds position.
- expand: now a thin wrapper over renderBlocks with a cascade-backed, per-slug
memoized loader (dedups the old N+1). Refs are discovered from the parse tree
(single source of truth = the MJML), so the redundant componentRefs arg is
dropped; callers updated (compose ×2, save, emailVersioning hook.test ×2).
- interpolate: rename VariablePrefix -> Lens, add `system` lens alongside
sender/recipient/data. Conditionals pick it up via flattenVariables.
Tests: parseBlocks (9), renderBlocks (8), interpolate +system (22); DB-backed
compose/save (30) and emailVersioning no-drift (6) green. Ticket updated with
the recomposeSnapshot slot-drift follow-up and the decided lens taxonomy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…e pass Verified parseBlocks/renderBlocks are lenient by design; enumerate the exact malformed cases the save-side slot validator must reject (bare passthrough text dropped at render, duplicate override names last-wins, unbalanced/crossed tags). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
The unsubscribe link is platform-injected, not recipient data — it belongs on
the system lens. Non-system templates now must carry an unconditional
{{system.unsubscribeUrl}} (save-time compliance check). settleTemplate's
per-kind var injection targets the system lens (recipientVarsForKind ->
systemVarsForKind).
save.test + interpolate green (40). Doc updated. (sendEmail.test has a
pre-existing, environment-specific circular-import load error unrelated to this
change — reproduced identically at HEAD.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…-010 slice 1)
Each template can declare a sender→recipient send matrix as a serializable
@inixiative/transitions Action ({ paths: [{ from, to }] }) — from = sender side,
to = recipient side. Guard-only governance, tenant-configurable in the DB.
- schema: EmailTemplate.matrix Json? (absent = no restriction)
- validateMatrix/assertValidMatrix: pure, domain-agnostic structural floor —
well-formed Action, each path a serializable transition (valid json-rules
predicates + valid ActionRule permission shapes) via validateTransition, no
lens yet (lens-scoped checks are the api boundary's job, slice 2).
- wired into saveEmailTemplate alongside the MJML/conditions validators.
- @inixiative/transitions added to @template/email (generic primitive, like
json-rules).
Tests: validateMatrix (10) + save persist/reject (2 new); 81 green across the
touched render surface. Design + slice plan in tickets/COMM-010.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
Define the sender/recipient asymmetry (sender = polymorphic model map, discriminated selection; recipient = always a User leaf + additive provenance overlay). Recipient defined: required User(id,name,email) leaf, optional provenance (organizationUser→organization / space parallel) bound from the send context, not walked from the user. Composition is an ordered, context-threaded pipeline (data → sender select+bind → merge → recipient bind → assert leaf → interpolate) that mirrors transitions' from→merge→to — guard and composition walk the same edge. Reslice: add 1b (lens-keyed matrix) + 2b (composeLenses). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…nditional composition
Correct the recipient model: cardinality (one sender vs a recipient SET) is the
real asymmetry, not User-ness. Recipient side = a name-keyed map of set-valued
LensNarrowing queries, OR-ed by the matrix `to`; multiplicity lives in the lens
(where = filter/level, binding present/absent = scope one-org-vs-all,
lens key = polymorphic type). Generalized leaf = email + Contact (User or
external Contact); recipient set = eligible(toLenses) bound to context.
Lens keys are unique descriptive names (parent model declared inside),
convention model-first + modifier-when-disambiguating; both sides uniform maps.
The declared lenses are one field vocabulary for interpolation, {{#if}}
conditionals, slots, and the guard — closing the lens-aware-validation gap
COMM-009's validateConditions explicitly parked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…lations Walk back the polymorphic-parent overreach. Recipient root = User (the person); org context, consent/address (User→contact), space, provenance all hang off the User via relations — nothing is a different root. Still set-valued (fan-out): "all org users"/"of this level" are relation-navigating where clauses; a polymorphic customer ref resolves DOWN to its User(s). Asymmetry sharpened to two axes — root (sender polymorphic model vs recipient always-User) and cardinality (one vs set). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
Lens selection happens once at the planner (sendEmail = send→deliver bridge). The lens is the hydration boundary (fetchLens+prune) and therefore the logic boundary — field/logic leakage structurally impossible. Encoding: the handoff already serializes prune(user, lens); extend it to prune-to-assigned-lens + a recipientLens key tag; lens definitions stay on the template. Collisions: logic/field enforced by prune (free); identity enforced by precedence-dedup by identity before the plan (existing idempotencyKey+skipDuplicates already collapse same-email, but winner is fetch order — precedence makes it deterministic). Key uniqueness free from object-map encoding. Slice 3 updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
Reshape send governance from inline-predicate matrix to the name-keyed lens
model the design converged on.
- schema: EmailTemplate.lenses Json? ({ senders, recipients, data } name-keyed
maps); matrix reshaped to { paths: [{ from: senderKey, to: recipientKey[] }] }.
- validateLenses (pure, structural): each lens declares a parent model + valid
json-rules `where`; recipient lenses must be parent: User with the id/name/email
delivery leaf (recipient root is always User, reason out via relations).
- validateMatrix(matrix, lenses): matrix keys are lens references — cross-check
every from ∈ senders and every to ∈ recipients; non-empty paths/to.
- both wired into saveEmailTemplate; domain-agnostic (model/field catalog checks
are the api boundary's job, slice 2).
- remove @inixiative/transitions from @template/email: structural validation is
json-rules-only; the checkTransition enforcement engine belongs at the api
boundary (slice 3), not the domain-agnostic render package.
Tests: validateLenses (9) + validateMatrix (11) + save persist/reject (3); 40
green across the governance surface, 39 pure render/interpolate unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…keeper, matrix is the maybe Pause point. The lens's primary job (safe-navigation interpolation surface — "what data shows up, who sees what") is the load-bearing, shipped value. The sender×recipient matrix multi-modality / multi-lens-per-template / precedence is the speculative part — "different template per recipient type" may be the simpler right answer. Don't build slices 2b/3 until the one-vs-many-templates call is made. Locked: governance-only, two-layer authoring (tenants select options, never compose lenses), system-emails-first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…e recipient lens Simple model wins: evolve the existing EmailEntry code registry (not the DB config) with one sender and one recipient lens per template — one path, one hydration boundary per side, no lens-selection logic. Different audiences = different templates via the existing multi-handoff bridge. DB lenses/matrix columns stay modeled but dormant. Interface upgrade: static recipient picks/relations (the save-time-knowable interpolation surface) + dynamic where(entity, sender) only. Multi-lens union/precedence/tenant-editable governance parked in COMM-011 with the join-is-the-real-target insight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
…B matrix/lenses Settle on the simple model: code registry, one sender + one recipient lens per template. Rolled back the DB-config experiment (columns + validators removed — no migrations existed; design preserved in COMM-010/011 and git history). - registry: RecipientDefinition splits static from dynamic — picks/relations declared statically (the template's recipient interpolation surface, knowable at save time), only where(entity, sender) is a closure. recipientLens() assembles the User-rooted narrowing, so the recipient-root-is-User invariant and the hydration boundary are enforced by construction. Entries migrated via a userRecipient helper. - sendEmail planner builds the lens from the definition (fetchLens/prune flow unchanged); test fixtures migrated. - registry.test.ts: lens assembly, relations passthrough, delivery-leaf invariant across all entries, entity-driven where. - schema: drop EmailTemplate.lenses/matrix; remove validateLenses/validateMatrix + save-path wiring and exports. Validation: email package 108 pass; registry.test 4 pass; emailVersioning 6 pass. (sendEmail.test.ts still carries its pre-existing, environment-specific module-load error — fixtures updated for the new shape regardless.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmyWtwoi36vdEEg4D6Nxms
Walks the parseBlocks AST and, per component ref, partitions caller overrides (kept inline, caller-owned) from the component's own body (chrome + :default slots, diffed against the cascade). Body == cascade → noop/inherit; diverged or new → a child-first component write. Nested refs attribute by region: refs in a :default are owned by the enclosing component; refs in an override bubble to the caller. Replaces the mapRefs/resolveVariants variant-indexing — no slug:idx, no fork-suffixing. Pure + DB-free (injected cascade resolver); 13 tests incl. the parent-ships-child-pre-filled nesting regression + child-first write ordering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…res)
EmailEntry becomes serializable data: entity/recipient where-conditions carry
{bind} tokens with a bindings map declaring each value's context path; sender is
a typed spec with bound id fields; data is a path-projection map. New pure
resolveEntry (resolveEntity/resolveSenderIdentity/resolveRecipients/resolveData)
fills bind values from the ordered context (data -> entity -> sender -> handoff)
and calls resolveLensBindings/resolveBindings. Planner (sendEmail) wired to the
resolver. Serializable registry + statically-derivable lens surface, no opaque
closures. 13 pure tests (registry + resolveEntry); email render + save DB suites
still green (70) + api email units (25).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
saveEmailTemplate now decomposes the hydrated payload against the owner cascade: per component, an inlined body equal to the resolved cascade body is a noop (inherit, no write); a divergence (or an unknown slug) writes the SAME slug at the current tier (shadow) — no slug:idx variants, no fork-suffixes. collectSlugs batches the cascade lookup. Rewrote save.test.ts to the noop/shadow/no-variant model (+ explicit org-shadow coverage). Removed the superseded extractRefs (mapRefs) + resolveVariants modules and their exports. Full render suite green (111) incl. save DB integration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The handler test built entries with the old closure shape (entity:(data)=>lens,
sender:()=>..., RecipientDefinition.where closure). Ported to the declarative
EmailEntry: entity {narrowing+bindings}, sender spec, RecipientSpec with {bind}
where + a bindings map (literal where values for fixed id-sets / cc). This path
was unrunnable until the enqueue import-cycle fix; now 7/7 pass, exercising the
bindings resolver end-to-end through the planner + DB (fan-out, cc, logging,
idempotency, opt-out, unsubscribe headers, undeliverable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
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
…atement 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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
agreenspan
force-pushed
the
feat/email-errors
branch
from
September 10, 2026 15:52
da66f61 to
8fcb7e6
Compare
…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
force-pushed
the
feat/email-authoring-headless
branch
from
September 10, 2026 16:11
bb2e1c6 to
1c7144b
Compare
… 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
agreenspan
changed the base branch from
feat/email-authoring-headless
to
main
September 10, 2026 16:27
This was referenced Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
collectRuleslives inconditionParser/,withRuleruns insettle/settleBranches(loop bindings resolved to absolute paths viaabsoluteRulebefore the lens judges them),composereturnsliveRuleRefs.The ruling (COMM-013, 2026-09-10)
Save refuses everything the lens can decide.
validateTokensruns 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,anygroups 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_ERRORSand the fallback tier walk are gone.settleTemplatereadsrender: { onIssue, substitute }fromapps/api/src/lib/email/registry.ts:fail(default) throws and the job retries to DLQ;degradesends with issues stored onCommunicationLog.renderIssues;substitutenames 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.
sendEmailthrows 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 typecheckclean except the pre-existing@template/sdkstale barrel.packages/email466,packages/db293,packages/shared136, api email/ruleReference/emailVersioning/emailTemplate/sendEmail/deliverEmail suites 90, CI rules pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP