Uh oh!
There was an error while loading. Please reload this page.
Extract merge-field markup into a shared module - #20
Merged
Conversation
The old regex matched exactly one dot, so a real captured token like
`{{ custom_objects.primary_document_record.id }}` (a relationship hop,
present in this repo's own automations fixtures) never matched at
all. finditer silently skipped it, so the literal `{{ ... }}` text
passed through html.escape and reached a recipient's message as
visible braces. The token grammar now accepts one or more
dot-separated segments after the namespace.
Three more divergences from what Kizen's builder UI actually writes,
found alongside the regex bug: `data-merge-field-objectname` is now
emitted for real custom-object namespaces (nothing in the repo emitted
it before); several reserved-namespace fallback labels now match
Kizen's real stored display names instead of a title-cased guess of
the api_name; and namespace classification uses an explicit allowlist
rather than "did a live lookup succeed" -- `contact` is a real,
queryable object api_name that is nonetheless reserved and carries no
objectname.
The label table stays deliberately small. A merge field's fallback
label is not a pure function of its token: the same token renders
different labels in different automations (`custom_objects.name` as
both "CD Activity Name" and "object with workflow Name"), so for
object-scoped namespaces live metadata resolution has to take
precedence over any static table, and a hardcoded value is only
defensible for namespaces with no live metadata source at all
(business, team_member, automation_variable).
The markup rules move to a new tools/merge_fields.py, taking resolved
labels via small callables rather than AutomationDef/LiveContext, so a
future email-template emitter can drive the same rendering without
depending on automation types. planners/automations.py's private copy
(_MERGE_FIELD_RE, _merge_field_label, _html_with_merge_fields) is
removed in favor of it.The email builder's merge-field pickers were captured in full and read back
from a saved template: 12 Business entries and 5 Team Member, matching the
pickers exactly. All 17 are now pinned, and the emitter reproduces every one
byte-identically against that capture.
These labels cannot be derived or fetched. Six of the twelve Business labels
are unreachable from the api_name by any transform — postal_code is
"Zip/Postal Code", reply_to_email is "Notification Email",
primary_marketing_contact_name is "Primary Name". The API is no help either:
the Business schema component lists 40 properties rather than these 12 and
omits country_code/state_code entirely, and no merge-field catalog endpoint
exists. The picker is curated, so the table has to be captured from the UI.
Tokens outside the picker lists now take a namespace prefix instead of a bare
title-cased field name, so a hand-authored {{ business.timezone }} reads as
"Business Timezone" rather than "Timezone".
Also corrects a test docstring that claimed none of its values were reachable
by a prefix-and-title-case rule; three of the five were.annaliu-kizen
approved these changes
Sep 1, 2026
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Sep 1, 2026
Merged
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 freeto 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.
Independent of the email-template stack — base
main, no dependency either way.Pulls merge-field markup into one shared module and fixes two real defects found
while writing it.
Why a module
A merge field is not a bare
{{ ns.field }}token. That renders as literalbraces in a recipient's inbox. Kizen's builder always wraps it:
That markup appears in automation notify steps,
call_llmandfile_content_extractionprompts, dashboard static text, and email templatetext blocks. Only the automations planner knew how to build it.
tools/merge_fields.pynow owns token parsing, span rendering, and namespace classification; callers
pass small resolver callables for live field/object display names, so the module
depends on no caller's types.
Two defects fixed
1. The token regex matched exactly one dot. So
{{ custom_objects.primary_document_record.id }}— present in a committedfixture — never matched, fell through to
html.escape, and rendered as visiblebraces in a recipient's message. Now one-or-more segments, with a test naming
the fixture so it can't be narrowed back.
2. Namespace classification used lookup success.
data-merge-field-objectnameis emitted only for real custom-object namespaces, and the old code decided that
by asking whether a live object lookup succeeded.
contactis itself a queryableobject api_name but is a reserved namespace that carries no
objectname, so itwas misclassified. Now an explicit allowlist, with a test asserting the resolver
isn't even called for reserved namespaces.
The label table
Labels are authoring-time snapshots, not functions of the token. Verified: the
same token carries different labels in different automations
(
custom_objects.nameisCD Activity Namein one andobject with workflow Namein another). So live field metadata resolves them wherever it can, and a static
table covers only namespaces with no queryable metadata source.
For
business/team_memberthe table is complete, not a sample — every entryin both builder pickers was inserted into a template and read back, and the counts
match the pickers exactly (12 and 5).
These labels can be neither derived nor fetched:
GET /api/docs/schema'sBusinesscomponent lists 40 properties, not these 12,and omits
country_code/state_codeentirely"<Namespace> " + title(api_name)rule gets 11 of 17;postal_code→Zip/Postal Codeandprimary_marketing_contact_name→Primary Nameare notreachable by any casing transform
automation_historyis deliberately excluded from the table for the reason above.Reviewing
7 files, +728/-85. The automations planner's three private merge-field helpers are
removed in favour of the module; behaviour there should be unchanged except for the
two fixes.
Worth challenging: the 17 pinned labels are only as good as the capture they came
from. An earlier revision of this work had two label values rejected in review as
unevidenced, correctly — they existed only in a chat message. If any look wrong,
say so and they'll be re-captured rather than argued for.