Norq

Data Schema and Samples Spec

Defines the data.schema.yaml and data.samples.yaml file formats for Norq notifications: JSON Schema structure, nullable semantics, sample naming and selection, and how the schema and samples interact with the linter, compiler, codegen, and SDK.

This is a prose-only specification. The schema system does not compile to channel output, so there are no example channel=... conformance test blocks. Schema semantics are enforced by the linter (crates/core/src/linter/template.rs) and the resolver (crates/core/src/resolver/mod.rs).


Overview

A notification data contract consists of two optional files placed alongside the channel templates inside the notification directory:

File Purpose
data.schema.yaml Declares the shape of runtime data (types, nullability)
data.samples.yaml Provides named test data sets for compile and test runs

Both files are optional. Without data.schema.yaml the linter cannot perform data path validation. Without data.samples.yaml the compiler and test runner receive no sample data and must be given data externally via --data.

The schema and samples are shared across all channels within a notification. There is one schema per notification and one samples file per notification; channel files do not define their own schemas.


data.schema.yaml

File location

data.schema.yaml must be placed directly inside the notification directory:

notifications/
  transactional/
    welcome/
      email.md
      sms.md
      data.schema.yaml     # ← here
      data.samples.yaml

Format

The file uses JSON Schema Draft 2020-12 written in YAML for readability. The YAML is parsed and converted to JSON internally before schema operations are applied. Any valid YAML representation of a JSON Schema is accepted.

The $schema key is optional but recommended for editor tooling:

$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
required: [user, action_url]
properties:
  user:
    type: object
    required: [first_name, email]
    properties:
      first_name: { type: string }
      last_name:  { type: string }
      email:      { type: string, format: email }
      tier:       { type: string, enum: [free, pro, enterprise] }
  action_url:     { type: string, format: uri }
  tracking_id:
    oneOf: [{ type: string }, { type: "null" }]

Root object requirement

The root schema must declare type: object. A schema that declares any other root type, or omits type entirely, may prevent the resolver from populating the schema field on the NotificationBundle and will limit the usefulness of linter checks.


Required fields and nullable semantics

The required array at each object level is the primary mechanism for expressing non-nullability. This is the same semantics JSON Schema uses for presence validation.

The nullability rule

A property is nullable (may be absent or null at runtime) if any of the following conditions hold:

  1. The property name is not listed in the parent object's required array.
  2. The property schema declares x-nullable: true.
  3. The property schema uses oneOf or anyOf with a { type: "null" } variant.

A property is non-nullable only when it is listed in required AND neither condition 2 nor condition 3 applies.

The linter evaluates nullability at each level independently. Nullability of a nested path like user.last_name is determined by:

  1. Checking that user is non-nullable at the root level.
  2. Checking that last_name is nullable within the user object schema.

If the parent segment itself is nullable, the child path is implicitly nullable regardless of the child's required status.

Nullable access warning

When the linter has a schema, it emits template/nullable-access (Warning) for every {{expression}} that resolves to a nullable path and is not protected by a guard. Two forms of guard suppress the warning:

  • An enclosing :::if directive that tests the nullable path (or any prefix of the path). Guarding user suppresses warnings for user.last_name.
  • A | default "..." pipe applied directly to the expression.
warning: Variable 'user.last_name' is nullable and accessed without a guard.
  Wrap in `:::if user.last_name` or use a `| default "..."` pipe.
  --> email.md:8:5
   |
 8 |     Hello {{user.last_name}}
   |           ^^^^^^^^^^^^^^^^^^
   = rule: template/nullable-access

Fix with an :::if guard:

::: if user.last_name
Hello {{user.last_name}}
:::else
Hello {{user.first_name}}
:::

Fix with a | default pipe:

Hello {{user.last_name | default user.first_name}}

The template/nullable-access rule also fires for nullable expressions inside YAML frontmatter fields (e.g., subject: "Hello {{user.last_name}}") and inside ternary expressions.

Undefined variable warning

When the linter has a schema, it emits template/undefined-variable (Warning) for every {{expression}} whose path cannot be resolved in the schema. This catches typos and stale references. The warning is suppressed if no schema is present.


Supported JSON Schema keywords

Norq uses a subset of JSON Schema. The following keywords are recognized by the linter and codegen. Unrecognized keywords are silently ignored.

type

The scalar JSON types supported are:

Value Description
string UTF-8 text value
number Floating-point number
integer Whole number
boolean true or false
object Key-value mapping
array Ordered list
"null" Explicit null type (in oneOf)

The type keyword is required for all properties. If absent, the resolver defaults to treating the property as a string.

properties

Declares the named properties of an object type. Each value is a nested schema. Properties not listed in properties may still appear in runtime data (the schema is not closed unless additionalProperties: false is set, though Norq does not enforce additionalProperties).

required

An array of property names that are guaranteed to be present and non-null at compile time. Names in required must match keys in properties. A required array at the root level applies to the root object; a required array inside a nested properties entry applies to that nested object.

type: object
required: [order_id, customer]    # root-level required
properties:
  order_id: { type: string }
  customer:
    type: object
    required: [name]              # nested required
    properties:
      name:  { type: string }
      email: { type: string }     # optional (nullable)

enum

Restricts a string property to a fixed set of allowed values. The linter does not validate that sample data conforms to enum values, but codegen uses them to generate union types / literal types.

status:
  type: string
  enum: [pending, shipped, delivered, cancelled]

String format

The following format values are recognized. Norq does not validate format constraints at runtime; format is advisory for documentation and tooling.

Value Meaning
email Email address
uri Absolute URI
date ISO 8601 date (YYYY-MM-DD)
date-time ISO 8601 date-time

array and items

An array property must include an items key whose value describes the schema of each element. The linter uses items to resolve paths inside :::each loop bindings.

line_items:
  type: array
  items:
    type: object
    required: [name, qty, price]
    properties:
      name:  { type: string }
      qty:   { type: integer }
      price: { type: number }

Usage in a template:

::: each order.line_items as item
- {{item.name}} x {{item.qty}} — ${{item.price}}
:::

Inside the :::each body, the loop variable (item) is bound to the items schema. Variable paths of the form item.name are resolved against the items schema rather than the root schema.

Nested objects (recursive)

Object properties can be nested to any depth. Each nested object follows the same rules: type: object, optional required array, properties map.

type: object
required: [user]
properties:
  user:
    type: object
    required: [profile]
    properties:
      profile:
        type: object
        required: [display_name]
        properties:
          display_name: { type: string }
          avatar_url:   { type: string, format: uri }

Path resolution walks into properties at each object level and into items for arrays. A dot-path like user.profile.avatar_url is nullable because avatar_url is not in the profile.required array.

Nullable via oneOf / anyOf

Explicitly nullable properties (those that can be string or null) use the oneOf pattern:

tracking_id:
  oneOf:
    - { type: string }
    - { type: "null" }

This is semantically distinct from simply omitting tracking_id from required. Both result in template/nullable-access warnings, but the oneOf form is preferred when the property IS expected to appear in required data that happens to allow a null value -- as opposed to a property that may simply be absent.

x-nullable extension

The x-nullable: true extension key marks a property as nullable without using oneOf. It is supported for compatibility with toolchains that emit this extension (e.g., OpenAPI generators):

notes:
  type: string
  x-nullable: true

A property with x-nullable: true triggers template/nullable-access warnings even if it is listed in required.

description

The description key on any property schema is surfaced by the LSP as hover documentation in the editor. It has no effect on compilation or linting.

tier:
  type: string
  enum: [free, pro, enterprise]
  description: "Subscription tier; determines feature access and rate limits"

data.samples.yaml

File location

data.samples.yaml must be placed directly inside the notification directory, alongside data.schema.yaml:

notifications/
  transactional/
    welcome/
      email.md
      data.schema.yaml
      data.samples.yaml    # ← here

Format

The file contains a top-level samples array. Each element has two required keys: name and data.

samples:
  - name: "New user"
    data:
      user:
        first_name: "Gaurav"
        email: "gaurav@example.com"
        tier: "free"
      action_url: "https://app.example.com/start"
 
  - name: "Pro user"
    data:
      user:
        first_name: "Priya"
        last_name: "Singh"
        email: "priya@example.com"
        tier: "pro"
      action_url: "https://app.example.com/start"
 
  - name: "Minimal"
    data:
      user:
        first_name: "Eve"
        email: "eve@example.com"
      action_url: "https://app.example.com/start"

Sample names

Sample names are arbitrary UTF-8 strings. They:

  • Must be unique within a notification. Duplicate names produce undefined behavior during sample lookup (the first match wins).
  • Are used verbatim as the value for --sample flags and the sample: key in tests.yaml. The comparison is case-sensitive and whitespace-sensitive.
  • Are displayed in the norq dev preview dropdown and the browser playground sample selector.

There are no constraints on length or character set. By convention, names should be short and descriptive: "New user", "Pro user", "Minimal".

Data structure

Each data object must be a YAML mapping. Values may be strings, numbers, booleans, arrays, nested mappings, or null. The data object is passed directly to the compiler as a serde_json::Value::Object.

If a data.schema.yaml is present, the linter validates that sample data conforms to the schema. Specifically:

  • Properties listed in required must be present in the sample data.
  • The linter emits a warning for required fields missing from sample data.
  • Extra properties in data not declared in properties are allowed (the schema is open by default).

The Minimal sample pattern

A common pattern is to include a "Minimal" sample that contains only required fields and omits all optional (nullable) ones. This exercises the template's conditional branches and | default fallbacks:

samples:
  - name: "Full"
    data:
      user:
        first_name: "Alice"
        last_name: "Smith"
      action_url: "https://app.example.com/go"
 
  - name: "Minimal"
    data:
      user:
        first_name: "Bob"
        # last_name omitted — tests the :::if guard
      action_url: "https://app.example.com/go"

Running both samples in norq test ensures that nullable-guarded sections are tested with the field both present and absent.


Sample selection

--sample flag

The norq compile, norq preview, and norq send commands accept a --sample <name> flag that selects a named sample from data.samples.yaml:

norq compile transactional/welcome --channel email --sample "Pro user"
norq preview transactional/welcome --channel email --sample "Minimal"
norq send transactional/welcome --to user@example.com --sample "New user"

The flag value must exactly match a name in the samples file. If the name is not found, the command fails with an error:

Error: sample 'Unknown' not found in data.samples.yaml

Default sample selection

When --sample is omitted and no --data flag is given, the compiler and preview commands automatically use the first sample in the samples array (index 0). This is consistent across norq compile, norq preview, norq send, and the norq dev live-reload server.

If data.samples.yaml is absent or contains an empty samples array, and neither --sample nor --data is provided, the command fails with:

Error: No data provided. Use --data or --sample.

--data flag

The norq compile and norq send commands also accept --data <file> to provide sample data from an external JSON file. This takes priority over --sample. When --data is provided, data.samples.yaml is not consulted.

norq dev preview server

The norq dev live-reload server populates a sample selector dropdown with all sample names from data.samples.yaml. Selecting a sample triggers a recompile with that sample's data. The default selection is the first sample.


Interaction with other systems

Linter

The linter receives the parsed schema as Option<&serde_json::Value>. When Some, two additional rules are active:

Rule Severity Trigger
template/undefined-variable Warning Expression path not found in schema
template/nullable-access Warning Nullable path accessed without :::if or `

Both rules are suppressed when schema is None.

The linter also fires template/undefined-variable for paths inside partial parameters (e.g., {{> card price=item.price}} where price is the variable path). This requires the schema to declare price (or the array items containing it) at the expected path.

Sample data is validated against the schema during norq lint. Missing required fields in a sample emit a lint warning.

Compiler

The compiler does not use the schema directly. It receives a serde_json::Value (the sample data) and resolves {{expression}} paths against it at compile time. If a path is absent in the data, the expression evaluates to an empty string. The schema is not consulted during compilation.

Test runner (norq test)

The tests.yaml file references samples by name using the sample: key:

tests:
  - name: "Email renders greeting"
    channel: email
    sample: "New user"
    assertions:
      - target: html
        op: contains
        value: "Hello Gaurav"

The test runner looks up the named sample from bundle.samples and uses its data as the compiler input. If sample: is absent from a test case, the runner iterates over all samples (equivalent to all_samples: true). If the named sample is not found, the test case fails with a diagnostic:

FAIL: sample 'Unknown' not found

Codegen (norq codegen)

The codegen system reads bundle.schema (the parsed data.schema.yaml) and converts it to a language-agnostic type representation (CodegenType). From that representation, it emits type-safe bindings for the configured target languages (TypeScript, Python, Go, Java, Ruby).

The codegen mapping follows these rules:

JSON Schema TypeScript Python Go
type: string string str string
type: integer number int int
type: number number float float64
type: boolean boolean bool bool
type: array ItemType[] List[ItemType] []ItemType
type: object interface Type {...} TypedDict / dataclass struct Type {...}
not in required field?: Type Optional[Type] *Type (pointer)
oneOf with null Type | null Optional[Type] *Type (pointer)
enum: [a, b] "a" | "b" Literal["a", "b"] string (constants)

Properties not in required are wrapped in CodegenType::Optional. Properties using oneOf with null are wrapped in CodegenType::Nullable. The codegen treats Optional and Nullable differently in some languages (Python Optional covers both; TypeScript distinguishes field?: T from field: T | null).

SDK send() at runtime

The SDK passes the data argument to the Norq binary for compilation. The binary does not perform JSON Schema validation against data.schema.yaml at runtime — schema validation is a development-time concern (linter, tests). Passing data that does not conform to the schema may result in empty expressions in the compiled output but does not raise an error.


Edge cases

Missing data.schema.yaml

If data.schema.yaml is absent, bundle.schema is None. The linter suppresses template/undefined-variable and template/nullable-access. The compiler compiles normally using whatever data is provided. Codegen generates Record<string, unknown> (TypeScript) or equivalent untyped bindings.

Missing data.samples.yaml

If data.samples.yaml is absent, bundle.samples is None. Commands that require data (norq compile, norq preview) must be given --data or --sample (the latter fails because there is no samples file). The test runner uses an empty data object {} when no samples are available and all_samples: true is set.

Schema with no required fields

A schema where required is absent or empty means every property at that level is nullable. The linter will warn on every {{expression}} that references a property of the root object unless guarded. This is a valid but unusual configuration — it forces every template expression to use :::if or | default.

type: object
properties:
  name: { type: string }
  email: { type: string }
# No required array → both 'name' and 'email' are nullable

Sample with extra fields not in schema

Sample data may contain properties not declared in properties. The schema is open by default (equivalent to additionalProperties: true). Extra properties in sample data are silently ignored by the linter's schema validation and are accessible to the compiler (expressions referencing them compile to their values). This allows gradual schema adoption.

Deeply nested objects

Path resolution (resolve_schema_path) walks through properties at each object level and into items for arrays. There is no depth limit. A path like order.shipment.address.city is resolved by traversing four schema levels. Each intermediate level must be of type: object with a properties map for the next segment to resolve. If any segment is missing from properties, the path is considered undefined and template/undefined-variable fires.

Arrays without explicit items schema

An array property without an items key is valid YAML but prevents the linter from resolving paths inside :::each loop bodies. The loop variable cannot be type-checked. Use the full items schema to enable nullable-access checking inside loops:

# Without items — loop body cannot be type-checked
tags:
  type: array
 
# With items — loop body is fully type-checked
tags:
  type: array
  items:
    type: object
    required: [label]
    properties:
      label: { type: string }
      color: { type: string }

Null values in sample data

Sample data may contain explicit YAML null values (null or ~). A null value for a required field is technically a schema violation, but the linter emits a warning rather than an error for this case to avoid blocking iterative development. The compiler resolves null sample values to empty string in template output.


Complete example

A notification with a full schema and two samples:

data.schema.yaml:

$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
required: [user, action_url]
properties:
  user:
    type: object
    required: [first_name, email]
    properties:
      first_name:  { type: string }
      last_name:   { type: string }
      email:       { type: string, format: email }
      tier:
        type: string
        enum: [free, pro, enterprise]
        description: "Subscription tier"
  action_url:   { type: string, format: uri }
  tracking_id:
    oneOf: [{ type: string }, { type: "null" }]
  items:
    type: array
    items:
      type: object
      required: [name, price]
      properties:
        name:  { type: string }
        price: { type: number }
        sku:   { type: string }

data.samples.yaml:

samples:
  - name: "Full"
    data:
      user:
        first_name: "Alice"
        last_name: "Smith"
        email: "alice@example.com"
        tier: "pro"
      action_url: "https://app.example.com/dashboard"
      tracking_id: "TRK-001"
      items:
        - { name: "Widget A", price: 19.99, sku: "WGT-A" }
        - { name: "Widget B", price: 29.99 }
 
  - name: "Minimal"
    data:
      user:
        first_name: "Bob"
        email: "bob@example.com"
      action_url: "https://app.example.com/dashboard"
      # tracking_id and items omitted — tests nullable/empty branches

With this setup:

  • user.first_name and user.email are non-nullable (in user.required).
  • user.last_name and user.tier are nullable — accessing them without a guard fires template/nullable-access.
  • tracking_id is nullable via oneOf — accessing it without a guard fires template/nullable-access even though it is not in any required array.
  • items[*].name and items[*].price are non-nullable within each item.
  • items[*].sku is nullable within each item.
  • The "Minimal" sample tests all nullable branches: tracking_id absent, items absent, user.last_name absent, user.tier absent.

Diagnostic rules summary

Schema-related lint rules and their severity:

Rule Severity Trigger
template/undefined-variable Warning Expression path not found in schema
template/nullable-access Warning Nullable path accessed without :::if guard or | default