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:
- The property name is not listed in the parent object's
requiredarray. - The property schema declares
x-nullable: true. - The property schema uses
oneOforanyOfwith 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:
- Checking that
useris non-nullable at the root level. - Checking that
last_nameis nullable within theuserobject 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
:::ifdirective that tests the nullable path (or any prefix of the path). Guardingusersuppresses warnings foruser.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: trueA 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
--sampleflags and thesample:key intests.yaml. The comparison is case-sensitive and whitespace-sensitive. - Are displayed in the
norq devpreview 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
requiredmust be present in the sampledata. - The linter emits a warning for required fields missing from sample data.
- Extra properties in
datanot declared inpropertiesare 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 nullableSample 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 branchesWith this setup:
user.first_nameanduser.emailare non-nullable (inuser.required).user.last_nameanduser.tierare nullable — accessing them without a guard firestemplate/nullable-access.tracking_idis nullable viaoneOf— accessing it without a guard firestemplate/nullable-accesseven though it is not in anyrequiredarray.items[*].nameanditems[*].priceare non-nullable within each item.items[*].skuis nullable within each item.- The "Minimal" sample tests all nullable branches:
tracking_idabsent,itemsabsent,user.last_nameabsent,user.tierabsent.
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 |