0050 — A NotificationTemplate is one whole message, and its policy says which one
Status: Accepted · 2026-08-22 · amended by 0051 (raw owns the colour, text and button placement; templates carry replies)
Supersedes: 0037 (Wordings, per-Stanza
overrides), and the two ADRs that extended it — 0048 (a Wording is spelled by its channel) and 0049
(a Wording is selected by its own clause), both withdrawn with this one.
Relates to: 0017 (the when clause this
stops duplicating), 0008 (why a rendered payload is hashed),
0034 (where a policy is written)
Context
Section titled “Context”ADR 0037 shipped Wordings: one Liquid template producing the text of one named block, four of
SPEC §H.7’s eight, with structure staying oto’s. The safety argument was sound and is worth restating,
because most of it survives: a Wording’s output type was a string, never a block, so Go still built
every block, assigned every block_id, and owned the attachment, colour and emoji. Nothing a customer
typed could reach the parts of the payload oto’s own control plane reads back.
It was safe, it was defensible, and it was the wrong product. Reviewers’ first question was consistently “so where is my template?”, and the honest answer was that there wasn’t one — there were four holes in somebody else’s card. A template a person can read top to bottom is one they can predict. Four independent overrides, each with its own matcher clause and its own priority, cannot be read that way and cannot be predicted without holding two resolution rules in your head at once: the routing one that chose the channel, and the presentation one that chose the words.
Every comparable product does whole-message: Alertmanager, Grafana, Datadog, Opsgenie. That is not fashion. It is that the unit somebody edits should be the unit that gets sent.
Decision
Section titled “Decision”A NotificationTemplate is one whole message, in one of three formats.
| format | the author writes | portable | oto owns |
|---|---|---|---|
card (default) |
Markdown plus two extensions | yes — compiled per provider | the action row’s identity |
text |
one flat string | yes | the sink |
raw |
literal Slack Block Kit JSON | no — Slack only | validation, block_ids |
It carries no when clause. notification_policies.template_id names it. A policy already has
matchers, already has reasons and already chose the destinations; it now also says what they read
like. One routing decision, one place to read it.
What makes a card portable: a document IR
Section titled “What makes a card portable: a document IR”A card template renders to Markdown, which is parsed into oto’s own small document IR — seven block
kinds, five span kinds — and each provider compiles that. Adding Discord is writing one compiler
over those twelve node kinds. It is not touching a template, a filter, or the parser.
The IR is deliberately smaller than Markdown. Every node in it has a defensible spelling in Block Kit, in a Discord embed, and in plain text. Tables, nested lists and images are refused at parse time with a sentence naming the alternative, rather than silently degraded — a table that renders as mangled prose at 03:00 is worse than one that never saved.
Markdown was chosen over HTML because it is already what a Slack or Discord user types in the very product this message is going to. HTML’s surface is enormous and we would support perhaps ten tags; every other tag becomes a support ticket. If email ever becomes a first-class channel, HTML becomes the native target and this decision is worth revisiting.
The IR is also what fixed the escaping
Section titled “The IR is also what fixed the escaping”The predecessor escaped runs of a finished string while walking past markup it had itself emitted.
Two live bugs came out of that shape: oto’s own <!date^…> token arrived as <!date^…>, and an
oto-issued link arrived defused inside a code span because defuseLinks could not tell it from an
address a label had smuggled in.
A SpanText is prose by construction. Escaping and audience-stripping and link-defusing happen
once, per span, and oto’s own markup is never in scope for any of them.
Links and timestamps are unforgeable handles, not URLs
Section titled “Links and timestamps are unforgeable handles, not URLs”Once Liquid has flattened a template into one string, https://oto.example/case/1 and
https://evil.example/phish are the same kind of thing. So oto does not put URLs into the binding at
all. It puts private-use handles, minted after sanitise() has stripped the entire private-use
area (U+E000–U+F8FF) from the template source and from every interpolated value — so the only handles
that can reach the parser are ones oto put there.
[text]({{ links.group }}) therefore works, and [click](https://evil.example) is refused with a
sentence naming the fix. A literal address typed by an author is prose, and meets DefuseLink like
any other bare URL.
Every value is escaped, unconditionally, with no opt-out
Section titled “Every value is escaped, unconditionally, with no opt-out”In card format every interpolated value is markdown-escaped. There is no {{{ }}}, no | raw, and
no taint tracking — because none is needed. An alert label is attacker-influenced: anyone who can fire
a metric can write one. A value that cannot produce syntax cannot produce structure, a link, a
mention, or a forged handle. An author who genuinely needs raw markup uses format: raw, which is
gated separately.
Control flow is oto’s own, on a basic engine
Section titled “Control flow is oto’s own, on a basic engine”Whole-message authoring needs iteration — nobody can render a label list without it. NewBasicEngine
ships zero tags and zero filters, and it can never be given the library’s: Engine.cfg is
unexported, so tags.AddStandardTags is unreachable from outside the package. The alternative was
NewEngine(), which brings thirteen tags — include and render among them, both of which read from
a template store — plus forty filters, and there is no UnregisterFilter.
So oto implements for and if/unless itself via RegisterBlock. Two consequences, both good:
- The iteration budget is a real counter — 250 per loop, 2000 per render, charged inside the loop
— rather than a source scan for
{% for %}nesting depth reasoning about a worst case. - There is no
{% else %}, becauseRegisterBlockcannot declare a clause.{% unless %}covers the case it usually serves. That is a real cost and it is smaller than thirteen unaudited tags.
The one rule that survives ADR 0037 intact: ordered slices, never maps. Go randomises map
iteration, oto hashes the rendered payload to suppress no-op chat.update calls, and a wobbling hash
re-sends the same card forever. asList refuses a bare map at the tag, and BuildInput offers a
slice for everything worth iterating — so members and trail, two of the four stanzas the slot
design had to refuse, are finally readable.
The action row is the operator’s to place, or to omit
Section titled “The action row is the operator’s to place, or to omit”{{ actions }} positions the button row. Leaving it out ships a card with no Acknowledge button.
That is permitted, deliberately, and it is a degraded card and not a lost alert: router.go:188
registers POST /api/v1/cases/{id}/ack, reaching the same service method the Slack button does. So
oto warns loudly — at save, and against the example that demonstrates it in the preview — and never
blocks. The save button stays enabled through the warning.
What is not the operator’s is the row’s identity. action_id is the dispatch key interactions.go
switches on and validate.go pins to ^oto\.[a-z0-9._]+$. A label an author can rewrite is a label;
an action_id they can rewrite is an alert nobody can acknowledge.
A mismatched provider is the operator’s headache, never a dropped alert
Section titled “A mismatched provider is the operator’s headache, never a dropped alert”A policy fans out to as many as sixteen channels and they need not share a provider. A template’s
provider field is declared intent: it drives the editor mode, the preview default and a
save-time warning. It gates nothing. card and text render anywhere; raw sent to a webhook falls
back to oto’s built-in card.
Every failure path — parse error, unknown filter, missing field, empty render, unsupported Markdown, JSON that interpolation broke, wrong provider entirely — returns “no template” and Go builds its own card. A template can never mark a delivery dead. That is the single most important property in the feature and it is pinned by a test that walks six distinct ways to be wrong.
Consequences
Section titled “Consequences”- ADR 0037’s per-Stanza vocabulary is retired.
Stanzaas a noun survives only inside the Slack renderer, describing blocks Go builds; it is no longer a customer-facing concept. - ADR 0037’s V0–V18 exhaustiveness gate is retired with it. Its argument — “output is a string, so
every structural check is unreachable” — stops being true the moment a template owns structure. The
replacement is narrower and more honest: a template-rendered payload goes through the same
Validateevery built-in card passes, asserted end to end. - A delivery records
(template_id, template_version)and not the source. A delivery row is written on every send and a template runs to 16 KiB. What makes the pointer sufficient is that the rendered payload is already on the same row: the bytes are never in doubt, only the attribution is. versionbumps only whensourceorformatchanges. A rename produced no different bytes on any card, and bumping then would claim two revisions that rendered identically.- Deleting a template is soft, and the policies naming it fall back to oto’s built-in card at the next delivery. The policy keeps pointing at the row; resolution stops returning it.
- A Dialect registry.
previewDialectsis a literal slice in two places. A provider shipped without a Dialect should refuse to construct; today it is simply a column the preview does not show. - Digests take no template. They are built by their own renderer and emit block names §H.7
does not have. The digest view stays in the fixture corpus as a robustness case. (Thread replies
were listed here too; ADR 0051 gives a template a
reply_source.) A— closed by ADR 0051:rawtemplate is validated for JSON shape, not for Block Kit validityrender/slackrunsValidateon a templated payload before choosing it, and falls back.