Norq

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:

  1. token.provider — explicit per-token override. MUST take precedence over all routing config.
  2. routing["push.<platform>"] — platform-scoped routing key (e.g., push.ios: apns). The <platform> segment MUST be one of ios, android, or web and MUST match the token’s platform.
  3. routing["push"] — channel-wide default for all push tokens.
  4. 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 routing block.
  5. 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:

  1. :::header directive content (highest priority)
  2. First Markdown heading (# Heading)
  3. Frontmatter title: field
  4. 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 Update

Priority 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 Alert

The 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 Shipped

Priority 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 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 days

Lists in Body

Unordered list

Unordered list items are prefixed with - and joined with \n.

---
title: Order Summary
---
 
- Widget A
- Widget B
.
- Widget A
.
- Widget B

Ordered 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 notifications

Emoji 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.