docs(website): the template editor as built (engagement Phase 5b)

ENGAGEMENT.md gains an "As built - 5b" section and BACKEND_DESIGN.md the eight
routes under /admin/engagement.

The scope decision is recorded first because the plan contradicted itself: the
Phase 5 body names only the editor, while Q4's answer and 6.2 both promise
"Triggers, Templates and the send log" in Phase 5. All three shipped - leaving
either out would have left the nav group half-built and G15 open with the rows
already on disk.

Five more decisions, each because the tree said something the plan did not: the
preview is rendered server-side and framed; `status` is now enforced by
renderByKey; a test send is logged under a synthetic trigger rather than making
the column nullable; a template a rule uses refuses deletion with a 409; and
duplicate is the only creation path.

Also records the correction that changed the most code - 4.6.2 says duplicate is
how a protected template is customized, the schema says "Editable, NOT deletable",
and the org lead's ruling is the schema's - and the three things only building it
found, including the Phase 4a template-key pattern that could not match any key
this system uses.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 18:14:12 -05:00
parent c03dfc14ba
commit 556124562b
2 changed files with 112 additions and 10 deletions

View File

@@ -2070,7 +2070,7 @@ refuses anything else, and it also refuses an id with no dot in it at all.
---
### Phase 5 — Templates: the seeded set, then the editor
### Phase 5 — Templates: the seeded set, then the editor
Two slices, landing in this order **on purpose** — the seeded set has to exist before the editor, so the
editor is opening something rather than facing a blank page.
@@ -2081,9 +2081,12 @@ transactional bodies move out of `mailer.js` into seeded rows and `mailer` rende
yet** — this slice is provably done when the same mail goes out from a template that used to come from a
string literal.
**5b — the editor.** The §4.6.2 surface: variable palette from the trigger declaration, live preview
from `example` values, side-by-side HTML/text, three preview widths plus dark mode, test send, duplicate.
Built on the existing block/prop-panel machinery, not a second one.
**5b — the editor.** The §4.6.2 surface: variable palette from the trigger declaration, live preview
from `example` values, side-by-side HTML/text, preview widths plus dark mode, test send, duplicate.
Built on the existing block/prop-panel machinery, not a second one — and, because a mail body is rendered
by the SERVER, the preview is rendered there too and framed rather than redrawn in React. It also lands
the other two screens Q4 promised Phase 5: **Triggers** (read-only, from the registries) and the
**Send Log** (G15).
**Acceptance (5a):** every one of the five current message types renders byte-comparably from its seeded
template; re-running the seeder is a no-op; a seeder bump updates a `customized = 0` row and **skips** a
@@ -2242,6 +2245,94 @@ accept and two real `.email` hosts it must still catch.
undeclared-variable refusal (`variablesFor` is in place and is what it will ask), the sandboxed preview
and its CSP test, and the `runicgateway.com` admin docs page §6.0b assigns the pair.
#### As built — 5b (2026-08-29)
Built as website#TBD. **Q4 was already settled** (Phase 4a), so the only thing needing a decision before
code was the scope: §Phase 5's body names the editor and nothing else, but Q4's answer and §6.2 both
promise "Triggers, Templates and the send log" in Phase 5. **All three shipped.** Triggers is a
read-only render of two endpoints Phase 2 already serves and cost no server work; the send log is one
paged route over a table that has been filling since Phase 4a and whose index was built for it. Leaving
either out would have left the nav group half-built and **G15 — "no send log, no delivery status, no
audit" — open with the rows already on disk.**
Five more decisions were settled by the org lead before any code, each because the tree contradicted
the plan or the plan contradicted itself.
| | What the tree said | Decision |
| --- | --- | --- |
| **Where the preview comes from** | The client block registry mirrors the server's, but its entry shape carries a **React `component`** — page blocks are drawn in the browser. Email blocks are drawn on the SERVER; a mail body is a string this process produces | **A server preview route**, rendered into a sandboxed iframe. A React preview would be a second renderer for one artifact, agreeing with the send path on the day it was written and drifting from the first Outlook fix onward |
| **`status` was unenforced** | `draft`/`published` shipped in 5a and **nothing read it** — `getByKey` returns any row, so an operator who saved a template as a draft kept mailing it | **`renderByKey` requires `published`** and otherwise falls back to the shipped seed, the posture 5a already built for a missing or unusable row. A draft now means what the word means |
| **Test send vs. `trigger_id NOT NULL`** | §4.6.2 wants a test send "recorded in `engagement_sends` like any other message", but every transactional template has `trigger_id` NULL and there was nothing honest to put in the column | **A synthetic `core.admin.test-send` id.** No schema change, no nullable column, and the log keeps meaning one thing. It is deliberately not a registered trigger, and the screen renders it by name so nobody goes looking for it in the catalog |
| **Deleting** | `protected` blocks deletion. Nothing stopped deleting a template a rule's `template_keys` points at | **409 while a rule uses it, naming the rules** — the answer Phase 4b already gives for a segment in use, for the same reason: the alternative is a rule that silently stops producing mail |
| **Creating** | §4.6.2 names duplicate and never mentions a blank create | **Duplicate only.** Every template on a deployment descends from a shipped one that renders, which is the whole reason 5a landed first |
##### The correction that changed the most code
§4.6.2 introduces duplicate as "how an operator customizes a `protected` template safely: duplicate,
edit, point the rule at the copy, leave the original intact". The schema comment written in 5a says the
opposite — "Editable, NOT deletable" — and **the org lead's ruling is the schema's: a default template
is edited in place.** `customized = 1` is what stops the next seed bump from taking that edit back, and
it has been in the UPDATE's own WHERE since 5a. So `protected` now blocks deletion and *nothing else*,
and duplicate is how a NEW template comes into being rather than how an existing one is customized.
##### Three things the plan did not know, found by building it
**1. The variable check cannot be a token scan, because of one block.** §4.6.2's refusal — "a template
referencing an undeclared variable is refused at save with the variable named" — reads as a scan for
`{{name}}`. It is not sufficient. `email.itemList.variable` holds a **bare name** (`items`), because the
block iterates the value rather than interpolating it; a token scan sees nothing there. A digest pointed
at `itmes` would have saved clean and arrived empty, which is the one variable mistake a reader of the
template cannot see. Blocks now optionally declare `variables(props)` in the registry — `itemList` is the
only one that does — and `emailBlocks/variables.js` walks tokens *and* declarations across the subject,
the text override and every block prop. The editor makes that field a `<select>` over the trigger's list
variables rather than a text input, so the mistake is unavailable in the first place.
**2. A duplicate that drops its seed reference cannot be saved.** The obvious shape for `create` was
`seed_key = NULL` — a copy is not a seed and must never be adopted by the seeder. But
`templates.variablesFor()` resolves the palette from the trigger *or, for the generic templates tied to
no trigger, from the seed*, so a seedless, triggerless copy has only the four ambient variables, and
**duplicating `notify.event` would have been refused for the `{{title}}` and `{{intro}}` it was copied
with** — the one action §4.6.2 offers, refusing itself. The copy inherits `seed_key`, and it is safe to
because `customized = 1` is what the seeder actually reads: `seedOne`'s UPDATE carries `AND customized =
0`, so it can only ever match the seeded row. `staleCustomized` *does* match a copy, and should — "the
default you duplicated has been improved" is worth telling someone.
**3. `validateEmailBlocks` returns `{ valid, errors }`, not an array** — and the first version of the
model tested it with `.length`, which is `undefined` on an object, so **block validation never ran at
all**. Unvalidated props would have reached both the renderer and the row. Nothing about the code looked
wrong; the test that caught it was the one asserting a preview refuses a block with an unknown prop.
`pages.model.js` destructures it, which is why that caller has never had the bug.
##### The one genuinely new CSP surface, and how it is held
§4.6.2 called the preview out as "the one genuinely new CSP surface", and it is: operator-authored HTML
reaching a rendered surface. Three things hold it, in order of what they cost to remove:
- The preview renders in `<iframe sandbox="" srcDoc={…}>` — **every restriction on, nothing granted
back**, from an opaque origin. No `allow-scripts`, no `allow-same-origin`.
- The HTML crosses as a **JSON string**, never as a document served from this origin. That is not a
convenience: served as a document it would run under the site's own CSP with access to its cookies.
- Blocks are validated then sanitized **on write**, against the `email.*` registry, so the stored row is
already through the gate before any of the above matters.
The sibling test §4.6.2 asked for is `client/test/emailTemplates.test.js`. It reads the **attribute**,
not the file — the first version searched the source for `allow-scripts` and failed on the comment above
the iframe explaining that there is no `allow-scripts`. A check a correct file fails is worse than no
check, because the way to make it pass is to delete the explanation.
##### What was verified
- **23 model tests** (`server/test/engagementTemplatesAdmin.test.js`), one per acceptance criterion plus
the two the tree made necessary. Server suite 1403, client 324, both green.
- **The drift check was verified by breaking it both ways** — a client `version: 2` against the server's
`1`, and deleting a registration outright — because a pairing check that spans a process boundary is
the kind that silently stops checking.
- **`renderByKey`'s draft arm has its own test**, asserting the seed goes out and the draft's words do
not; the 5a fixture that carried no `status` was updated rather than the rule being softened.
**Still Phase 6's:** the email channel's `deliver` on the engine, and the `teamNotify` /
`teamDigestWorker` migration onto the four `notify.*` templates this phase can now edit.
---
### Phase 6 — The email channel on the engine, and the Teams migration