Skip to content

Extract merge-field markup into a shared module - #20

Merged
jbedient-kizen merged 2 commits into
mainfrom
feat/merge-fields-module
Sep 1, 2026
Merged

Extract merge-field markup into a shared module#20
jbedient-kizen merged 2 commits into
mainfrom
feat/merge-fields-module

Conversation

@jbedient-kizen

Copy link
Copy Markdown
Contributor

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 literal
braces in a recipient's inbox. Kizen's builder always wraps it:

<spanclass="kzn-merge-field"
data-merge-field-fallback-label="Stage"
data-merge-field-relationship="object_with_workflow.stage"
data-merge-field-objectname="object with workflow">{{ object_with_workflow.stage }}</span>

That markup appears in automation notify steps, call_llm and
file_content_extraction prompts, dashboard static text, and email template
text blocks. Only the automations planner knew how to build it. tools/merge_fields.py
now 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 committed
fixture — never matched, fell through to html.escape, and rendered as visible
braces 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-objectname
is emitted only for real custom-object namespaces, and the old code decided that
by asking whether a live object lookup succeeded. contact is itself a queryable
object api_name but is a reserved namespace that carries no objectname, so it
was 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.name is CD Activity Name in one and object with workflow Name
in 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_member the table is complete, not a sample — every entry
in 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's Business component lists 40 properties, not these 12,
    and omits country_code/state_code entirely
  • no merge-field catalog endpoint exists — 0 of 564 schema paths match "merge"
  • a "<Namespace> " + title(api_name) rule gets 11 of 17; postal_code
    Zip/Postal Code and primary_marketing_contact_namePrimary Name are not
    reachable by any casing transform

automation_history is 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.

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.
@jbedient-kizen
jbedient-kizen merged commit c15d61e into mainSep 1, 2026
4 checks passed
@jbedient-kizen
jbedient-kizen deleted the feat/merge-fields-module branch September 1, 2026 14:17
Sign up for freeto 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

@jbedient-kizen@annaliu-kizen