To ensure the long-term sustainability of this project, users of this package who generate revenue must pay an Open Source Maintenance Fee. While the source code is freely available under the terms of the License, this package and other aspects of the project require adherence to the Maintenance Fee.
To pay the Maintenance Fee, become a Sponsor at the proper OSMF tier. A single fee covers all of Devlooped packages.
JSON Schema is excellent at shape: types, required fields, formats, enums. It is awkward at business rules that cross fields, compare against policy at the document root, or select a subset of nodes and assert something about each.
Jsontron adds that missing layer on top of
JsonSchema.Net: Schematron-style
rules, assert, report, and let keywords whose context and test
expressions are jq, evaluated by
Devlooped.JQSharp.
- Structural validation stays with JSON Schema.
- Cross-cutting rules stay in the schema, next to the data they describe.
- jq expressions are parsed once when the schema is built, then reused.
using System.Text.Json;
using Json.Schema;
using Jsontron;
// Once per process — extends Dialect.Default with rules/assert
MetaSchemas.Register();
var schema = JsonSchema.FromText(
"""
{
"type": "object",
"required": [ "orders", "policy", "approvedCustomers" ],
"rules": [
{
"context": ".orders[] | select(.total > 1000)",
"asserts": [
{
"test": ".discount >= ($root.policy.minHighValueDiscount // 0.1)",
"message": "High-value order \\(.id // \"?\") discount \\(.discount) is below policy"
},
{
"test": ".customer.id | IN($root.approvedCustomers[])",
"message": "Customer \\(.customer.id) is not on the approved list"
}
]
}
]
}
""");
using var doc = JsonDocument.Parse(
"""
{
"policy": { "minHighValueDiscount": 0.15 },
"approvedCustomers": [ "acme", "globex" ],
"orders": [
{
"id": "O-1001",
"total": 2500,
"discount": 0.05,
"customer": { "id": "initech" }
},
{
"id": "O-1002",
"total": 80,
"discount": 0,
"customer": { "id": "unknown" }
}
]
}
""");
var result = schema.Evaluate(
doc.RootElement,
new EvaluationOptions { OutputFormat = OutputFormat.List });
// O-1001 fails both asserts (discount + customer).
// O-1002 is ignored by the rule (total ≤ 1000).
Console.WriteLine(result.IsValid); // Falsejq already knows how to walk JSON: filter arrays, default missing fields, compare values, and build messages with string interpolation. Jsontron reuses that instead of inventing another expression language.
| Idea | In Jsontron |
|---|---|
| Select nodes to check | context — jq filter over the current instance |
| Predicate that must hold | asserts[].test — jq filter with . = each context node |
| Human-readable failure | asserts[].message — jq expression → string |
| Document root | $root (always bound for context, test, and message) |
An assert fails when the test is not jq-truthy (false, null, or an empty
stream) — the same idea as Schematron assert. A report is the inverse:
it fails when the test is truthy.
Full form: an array of rules. Each rule picks context nodes, then runs one or more asserts.
{
"rules": [
{
"context": ".lineItems[] | select(.sku | startswith(\"PROMO-\"))",
"asserts": [
{
"test": ".qty <= ($root.promotions[.sku].maxQty // 1)",
"message": "Promo \\(.sku) allows at most \\($root.promotions[.sku].maxQty // 1), got \\(.qty)"
}
]
}
]
}When the rule is “about this node,” skip the full rules array:
{
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 0 }
},
"assert": ".age >= 18 or (.guardianEmail | type) == \"string\""
}Forms accepted:
| JSON | Meaning |
|---|---|
"assert": "<jq>" |
One assert; test and message source are that expression |
"assert": { "test", "message" } |
Explicit message (still a jq string expression) |
"assert": [ ... ] |
Several asserts, all on the same context: "." rule |
{
"assert": [
".status | IN([\"draft\", \"submitted\", \"approved\"])",
{
"test": ".status != \"approved\" or .approver != null",
"message": "Approved documents require an approver"
}
]
}If both assert and rules appear on the same schema object, sugar asserts are
merged into the rule with context: "." (created if needed) and evaluated once.
report is the Schematron counterpart of assert: it fails when the test IS jq-truthy.
Use it to call out forbidden states rather than require good ones.
{
"report": {
"test": ".password | test(\"(?i)password\")",
"message": "password must not contain the word 'password'"
}
}Shape-wise, report is identical to assert — string, object {test, message},
or array of those — and inside a full rule it is a sibling array:
{
"rules": [
{
"context": ".orders[]",
"asserts": [
{ "test": ".id | type == \"string\"", "message": "order id required" }
],
"reports": [
{ "test": ".total < 0", "message": "order \\(.id) has negative total" }
]
}
]
}A rule may have asserts, reports, or both. Top-level report sugar alongside
rules merges into the context: "." rule just like assert does, so you can mix
all three on the same schema object.
let binds $name jq variables so expensive expressions, policy lookups, or
derived values can be computed once and reused. Allowed at the schema level and
inside each rule object:
{
"let": {
"cap": ".policy.maxLineItems",
"activeCustomers": ".customers | map(select(.active)) | map(.id)"
},
"rules": [
{
"context": ".orders[]",
"let": { "lines": ".lineItems | length" },
"asserts": [
{
"test": ".customerId | IN($activeCustomers[])",
"message": "customer \\(.customerId) is not active"
},
{
"test": "$lines <= $cap",
"message": "order \\(.id) has \\($lines) lines, over cap \\($cap)"
}
]
}
]
}Semantics:
- Order: entries evaluate in document order; later bindings see earlier ones.
- Scope: schema-level
letis visible to every rule at that schema location (context, test, message, and rule-levellet). Rule-levelletis scoped to that rule only, evaluated aftercontextproduces each node (so.is the context node), and shadows schema-level bindings with the same name for that rule. - Value: each expression's first stream result is bound. If the expression
yields an empty stream,
nullis bound. - Reserved:
$rootis always the document root —letcannot bindroot. Names must match[A-Za-z_][A-Za-z0-9_]*.
Rules, asserts, and reports accept optional id (human-readable label) and, on
asserts/reports, flag (severity label, with role as a Schematron-parity alias).
These are additive metadata only — they do not change pass/fail semantics; they
prefix the failure message so output is easier to triage.
{
"rules": [
{
"id": "orders",
"context": ".orders[]",
"asserts": [
{
"id": "high-value-discount",
"flag": "warning",
"test": ".total <= 1000 or .discount >= 0.1",
"message": "order \\(.id) discount is below policy"
}
]
}
],
"report": {
"id": "forbidden-flag",
"role": "error",
"test": ".forbidden == true",
"message": "forbidden flag set"
}
}idis any non-empty string.flagsuggested values:"error","warning","info"(any non-empty string is accepted; no enum).roleis the Schematron alias offlag. Setting both on the same entry is a build-time error.- Prefix order:
[<rule-id>][<assert-id>][flag=<value>] <message>. Any missing piece is omitted.
For the first assert above on a failing order O-1, the message would be:
[orders][high-value-discount][flag=warning] order O-1 discount is below policy
assert / rules apply to the instance at that schema location. Under
properties / items, . is the nested value; $root remains the full document.
{
"type": "object",
"properties": {
"shipments": {
"type": "array",
"items": {
"type": "object",
"required": [ "weightKg", "method" ],
"assert": {
"test": ".method != \"air\" or .weightKg <= $root.limits.maxAirKg",
"message": "Air shipment \\(.id // \"?\") exceeds max air weight"
}
}
}
}
}message is a jq expression that should produce a string. If it does not
start with ", Jsontron wraps it as a jq string for you so plain text just works:
"message": "Discount too low"Use jq interpolation when you want values in the text (escape backslashes in JSON):
"message": "Line \\(.sku): qty \\(.qty) exceeds cap"Or start with " yourself for a full jq string expression.
MetaSchemas.Register();That:
- Extends
Dialect.Default(andBuildOptions.Default.Dialect) withrules/assert/report/let - Registers the vocabulary and meta-schema
(https://www.schemastore.org/jsontron-0.2.json)
After Register(), ordinary JsonSchema.FromText / Build calls pick up the keywords
with no extra BuildOptions plumbing.
Keyword syntax is also published as schemas/jsontron-0.2.json.
- Build-time compile: invalid keyword shape or invalid jq fails when the schema is built, not on the first instance.
- No rule shadowing (v1): every rule runs (closer to Schematron 2025
groupthan classic pattern shadowing). - Empty context: if
contextmatches nothing, asserts do not run — vacuously valid. - Stack: JsonSchema.Net 9.x + Devlooped.JQSharp 1.0.2+ (evaluation-time variables for
$root).