Push Channel Conformance Spec
Conformance tests for the Norq push notification compiler. The push compiler
takes Markdown input (with optional YAML frontmatter) and produces a
PushOutput with title, body, image, action_url, and platforms
fields.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals, as shown here.
Each example block specifies input markdown and one or more expected output
fragments (substrings that must appear in the compiled output).
Format:
- Opening fence: 32 backticks followed by
example channel=push - Input markdown (first section, before the first
.separator) .separator line- One or more expected fragments, each separated by
.lines - Closing fence: 32 backticks
The test runner serializes push output as <title>\n<body>. Fragments can
match either the title or body field.
Recipient
A push recipient is identified by one or more device tokens. Each token MUST
carry a platform field; the remaining fields are conditionally required or
optional.
Token Shape
{
"push": {
"tokens": [
{ "token": "abc123", "platform": "ios" },
{ "token": "def456", "platform": "android" },
{
"token": "https://updates.push.services.mozilla.com/wpush/v2/...",
"platform": "web",
"keys": { "p256dh": "BPxx...", "auth": "Kxxx..." }
},
{ "token": "ExponentPushToken[xxx]", "platform": "ios", "provider": "expo" }
]
}
}| Field | Required | Type | Description |
|---|---|---|---|
token |
MUST | string | Provider-specific device token. For Web Push, this is the browser subscription endpoint URL. |
platform |
MUST | string | One of ios, android, or web. |
provider |
MAY | string | Overrides the channel-level provider routing for this token. MUST match a configured provider name. |
keys |
MUST if platform is web |
object | Web Push encryption keys from the browser’s PushSubscription. MUST contain p256dh and auth string fields. |
environment |
MAY | string | One of "sandbox" or "production". APNs-only — overrides the provider-level production flag. Tokens without this field MUST fall back to the provider’s configured value. Implementations MUST reject any other string with push/invalid-environment. |
A conforming implementation MUST reject any token where platform is absent or
not one of the allowed values. A conforming implementation MUST reject a Web
Push token where keys is absent or where either p256dh or auth is absent.
Provider Routing
For each token, the provider MUST be resolved in highest-specificity-first order:
token.provider— explicit per-token override. MUST take precedence over all routing config.routing["push.<platform>"]— platform-scoped routing key (e.g.,push.ios: apns). The<platform>segment MUST be one ofios,android, orweband MUST match the token’splatform.routing["push"]— channel-wide default for all push tokens.- Single-provider auto-fallback — when none of the keys above match but
exactly one push-capable provider is configured, that provider MUST be
selected automatically. This makes single-provider setups work without an
explicit
routingblock. - Unroutable token — if no provider can be resolved after the four steps
above, a conforming implementation MUST surface an error identifying the
unroutable platform (e.g.,
provider/no-route-for-push.ios).
Title Resolution
Push notifications have a single title string. The compiler resolves it from four sources in priority order:
:::headerdirective content (highest priority)- First Markdown heading (
# Heading) - Frontmatter
title:field - Empty string (when no source is found)
This priority order means :::header always wins over a heading, and a heading
always wins over frontmatter title:.
Priority 1: :::header directive
:::header content becomes the title. Any frontmatter title: field or
Markdown heading is ignored.
---
title: Ignored Title
---
:::header
New Message
:::
You have a new message.
.
New Message:::header with an expression in its content resolves the expression with the
current data. With null data, unresolvable paths produce empty strings, so the
static text portion is what the title contains.
:::header
Order Update
:::
Your package is ready for pickup.
.
Order UpdatePriority 2: First Markdown heading
When no :::header is present, the first heading (any level) becomes the
title. The heading text is excluded from the body.
# Account Alert
Your account needs attention.
.
Account AlertThe heading is skipped in the body because it was consumed as the title. Only the paragraph appears in the body.
# Package Delivered
Your package arrived at the door.
.
Package Delivered
.
Your package arrived at the door.Priority 3: Frontmatter title:
When neither :::header nor a heading is present, the frontmatter title:
field becomes the title.
---
title: Order Shipped
---
Your order is on its way.
.
Order ShippedPriority 4: Empty title
When no :::header, heading, or frontmatter title: is present, the title is
an empty string.
Just a body paragraph with no title source.
.
Just a body paragraph with no title source.Body Text
The body is plain text assembled from all content nodes except those consumed as the title source. Inline formatting markers (bold, italic) are stripped to plain text.
Paragraphs
Each paragraph contributes its text to the body. Consecutive paragraphs are
joined with \n.
Your package is on its way!
.
Your package is on its way!Multiple paragraphs
---
title: Notification
---
First line of info.
Second line of info.
.
First line of info.
.
Second line of info.Inline bold stripped
Inline formatting is stripped; only the plain text is kept.
Your order **#12345** has shipped.
.
Your order #12345 has shipped.Body truncated at 200 characters
The body is truncated to approximately 200 characters. If the text exceeds 200
characters, it is cut at the last space before the limit and ... is appended.
---
title: Long Notification
---
This is a very long body text that goes well beyond the two hundred character limit enforced by the push notification compiler so that it will definitely be truncated with an ellipsis at the end when compiled.
.
...Directives in Body
:::header excluded from body
:::header sets the title and is not included in the body.
:::header
Notification Title
:::
This is the body text.
.
This is the body text.:::footer excluded from body
:::footer is silently ignored in push output. Its content does not appear in
the title or body.
Your order is confirmed.
:::footer
You received this because you placed an order.
:::
.
Your order is confirmed.:::action excluded from body
:::action content sets action_url but does not contribute to the body
text. The link label and URL do not appear in the body.
Your package arrived.
:::action
[View Details](https://example.com/order/123)
:::
.
Your package arrived.:::highlight — star prefix in body
:::highlight content is prefixed with the ⭐ star emoji (U+2B50) and
included in the body.
:::highlight
Flash sale ends tonight
:::
.
⭐ Flash sale ends tonight:::callout — warning prefix in body
:::callout content is prefixed with the ⚠ warning emoji (U+26A0) and
included in the body.
:::callout
Your password expires in 3 days
:::
.
⚠ Your password expires in 3 daysLists in Body
Unordered list
Unordered list items are prefixed with - and joined with \n.
---
title: Order Summary
---
- Widget A
- Widget B
.
- Widget A
.
- Widget BOrdered list
Ordered list items are prefixed with N. (1-based).
---
title: Steps
---
1. Open the app
2. Tap notifications
.
1. Open the app
.
2. Tap notificationsEmoji Shortcodes
Emoji shortcodes are expanded in both the title and body. The title shortcodes
are expanded in the extract_title step; body shortcodes are expanded in
extract_body.
:::header
Launch :rocket:
:::
Deployment started.
.
Launch 🚀Hello :wave: welcome!
.
Hello 👋 welcome!Hero Image
:::hero sets the image field of PushOutput. The image field is not
included in the <title>\n<body> test runner output, so it is tested only via
unit tests in push.rs. The hero directive content does not appear in the
body.
Platform Frontmatter
The frontmatter keys ios, android, and web are extracted into the
platforms field of PushOutput. They MUST NOT appear in the body. Platform
configuration is tested via unit tests in push.rs.
Allowlists
Implementations MUST validate frontmatter platform-key blocks against the
following allowlists. Unknown keys SHOULD warn (linter rule
push/unknown-{ios,android,web}-key); invalid enum values MUST error
(push/invalid-priority, push/invalid-interruption-level).
iOS keys (mapped to APNs aps dictionary):
| Key | Type | Notes |
|---|---|---|
sound |
string | APNs sound file name. |
badge |
number | numeric string | Badge count. Numeric strings produced by {{interpolation}} MUST be coerced to integers; non-numeric values MUST error (push/badge-must-be-number). |
category |
string | Notification category identifier. |
thread-id |
string | Conversation thread grouping ID. |
mutable-content |
boolean / 1 | Enables UNNotificationServiceExtension. |
content-available |
boolean / 1 | Background-update flag. |
interruption-level |
enum | One of passive, active, time-sensitive, critical. Other values MUST error. |
Android keys (mapped to FCM v1 android.notification and android blocks):
| Key | Type | Notes |
|---|---|---|
channel, channel_id |
string | Android notification channel ID. Either spelling is accepted. |
priority |
enum | One of normal, high. Lowercase only — FCM v1 is case-sensitive. Other values MUST error. |
color |
string | Notification accent color (hex). |
icon |
string | Drawable resource name. |
sound |
string | Custom sound resource. |
tag |
string | Coalescing tag. |
ttl |
number | duration string | Time-to-live; emitted as a "<seconds>s" Duration string in the FCM body. |
Web keys (mapped directly to the Notification constructor options):
| Key | Type | Notes |
|---|---|---|
icon |
string (URL) | Notification icon. |
badge |
string (URL) | Status-bar badge image (Android Chrome). |
image |
string (URL) | Hero image. |
requireInteraction |
boolean | Keep notification visible until user dismisses. |
actions |
array | Action button definitions. |
tag |
string | Coalescing tag. |
renotify |
boolean | Re-alert when a tagged notification is replaced. |
Frontmatter interpolation
Compilers MUST interpolate string values inside ios:, android:, and web:
frontmatter blocks against runtime data. Non-string scalars (numbers, booleans)
MUST pass through unchanged. Example:
ios:
badge: "{{unread_count}}"
category: "MESSAGE_{{kind}}"
android:
channel: "{{channel_id}}"When unread_count resolves to 7, ios.badge MUST be the string "7" after
interpolation; the provider layer (FCM, APNs, Expo) MUST then coerce numeric
strings to integers for badge and emit a push/badge-must-be-number error
when coercion fails. This keeps templates declarative — authors write the same
{{...}} syntax everywhere — while preserving the wire-format types each push
gateway expects.
Implementations MUST emit push/missing-platform when a token’s platform
is absent and push/invalid-platform when it is not one of the three allowed
values.
Push Providers
Push delivery is fan-out by token: every push send produces one HTTP request per token, routed by the resolution rules in Provider Routing. Norq ships four built-in push providers. Per-provider configuration is implementation guidance, not a conformance requirement of this spec — see the Norq docs for details.
| Provider | Platforms | Auth variant emitted | Endpoint |
|---|---|---|---|
fcm |
iOS (via APNs proxy), Android, Web | OAuth2GoogleSA |
https://fcm.googleapis.com/v1/projects/{projectId}/messages:send |
apns |
iOS only | AppleJwt |
https://api.{sandbox.}push.apple.com/3/device/{token} |
expo |
iOS, Android | Bearer (when accessToken set) or None |
https://exp.host/--/api/v2/push/send |
webpush |
Web only | VapidWebPush |
The browser subscription endpoint URL (per token) |
The CLI’s HTTP executor uses HTTP/1.1, so providers requiring HTTP/2 (notably
APNs, which mandates HTTP/2 frames) cannot be invoked directly from
norq send. CLI sends to APNs MUST surface provider/cli-http2-required.
SDKs implement HTTP/2 via their host language’s runtime and MUST execute APNs
sends transparently. Bearer-style providers (FCM via signed OAuth2 token, Expo,
Web Push) are callable from both the CLI and SDKs.
Blockquote
Blockquote paragraph children are rendered as plain text in the body. The >
marker is not emitted.
---
title: Quote
---
> This is an important message.
.
This is an important message.