| title | Executable Rules |
|---|
Rules check content after JSON Schema validation, for example cross-node checks and workflow constraints. A schema declares rules in $metadata.rules. A rule has an expression, a semantic part, or both:
- An
expressionis a JSONata test that must returntrue. - A
semanticpart asks a model to evaluate a node. See Semantic rules.
A rule with neither is a schema error.
For metadata structure and composition behavior, see docs/schemas.md.
Rules are a flat array.
"rules": [
{
"id": "active-outcome-count",
"category": "workflow",
"description": "Only one outcome should be active at a time",
"global": true,
"expression": "$count(nodes[resolvedType='outcome' and status='active']) <= 1"
}
]Every rule has a category, which describes its intent and supplies a default severity.
| Category | Typical use | Default severity |
|---|---|---|
validation |
Hard correctness constraints | error |
coherence |
Cross-node consistency checks | warning |
workflow |
Process/operating-discipline checks | warning |
best-practice |
Advisory quality checks | info |
Severity can also be set per rule. It applies to a failed expression and to the default fail verdict of a semantic rule.
| Severity | Meaning | Fails validation |
|---|---|---|
error |
Content is wrong | yes |
warning |
Content is likely inconsistent or incomplete | no |
info |
Content could be better | no |
Only error violations fail validation: sctx validate and sctx validate-file exit non-zero, and JSON output reports valid: false. Warnings and info violations are reported but don't fail, so a work-in-progress space can still pass while advisory checks remain visible.
Prefer choosing the right category and relying on its default. Set severity on an individual rule only when that rule genuinely differs from the rest of its category:
{
"id": "goal-not-blocked",
"category": "workflow",
"severity": "error",
"description": "Goals must not be left blocked",
"type": "goal",
"expression": "current.status != 'blocked'"
}sctx schemas show lists each category with its default severity and marks rules that override it.
- A rule with
global: trueruns once for the space. It needs anexpression, and it cannot havewhen,typeorsemantic. - Other rules run once for each node.
typelimits a rule to nodes with thatresolvedType.whenlimits a rule to nodes where the applicability condition returnstrue. See Conditional rules.
when is the rule's applicability condition: a JSONata test that determines whether a rule applies to a node. It has the same context as expression and runs before it. Use when when type cannot select the nodes a rule applies to:
{ "id": "ready-has-owner", "category": "workflow", "type": "task",
"description": "Ready tasks have an owner",
"when": "current.status = 'ready'", "expression": "$exists(current.owner)" }when costs almost nothing, so use it to stop an expensive rule from running where it does not apply. For a semantic rule, this saves a model call on each node that when excludes.
A rule runs its parts in this order: when, then expression, then the semantic part. If the expression does not return true, the semantic part does not run on that node.
A node with a gating violation from an expression has none of its semantic parts evaluated. A non-gating violation, such as info, does not stop them.
An error in a when or expression is reported as a non-gating error violation.
An expression (and when) is JSONata. This section covers what an expression can see and how to write expressions.
Each evaluation receives:
| Variable | Description |
|---|---|
nodes |
All nodes in the space |
current |
Current node for this evaluation |
parent |
First resolved parent node (if any) |
parents |
All resolved parent nodes (if any) |
Useful resolved fields on nodes:
resolvedTypeid: the node's unique key. Compare nodes byid, since titles may repeat.resolvedParentId,resolvedParentIds: theidof the first parent, and of all parentsresolvedParentTitle,resolvedParentTitles: the display titles of the same parents
Prefer resolvedType over raw type so aliases are handled correctly.
Inside nodes[...], bare names refer to each candidate node. Use $$ to reference outer variables:
$count(nodes[resolvedParentId=$$.current.id and resolvedType='solution'])
$exists(current.metric) = true
$count(current.sources) >= 1
$count(nodes[resolvedType='outcome' and status='active']) <= 1
current.status != 'active' or $exists(parent) = false or parent.status = 'active'
Dates and datetimes reach rules as ISO 8601 strings. Convert with $toMillis() to compare them, including date against datetime, and use $millis() for the current time:
$not($exists(current.due)) or $toMillis(current.due) > $millis()
$toMillis(current.stale_after) > $toMillis(current.generated.at)
A datetime without an offset (2026-07-01T00:00:00) is read in the machine's local time zone, so require an offset with format: "date-time" when results must not depend on where validation runs. $toMillis() also needs seconds: it cannot parse a minute-precision datetime such as 2026-07-01T09:15, which format: "date-or-date-time" accepts. A value $toMillis() cannot parse makes the expression fail.
The same applies to filter expressions: compare dates with $toMillis(), not as strings.
To share rules, put them in a schema's $metadata.rules and list that schema's $id in $metadata.imports. An import brings the whole schema's metadata, including its own imports. Merged rules combine by id; a duplicate id is an error, and a later rule replaces an earlier one only with override: true.
"$metadata": {
"imports": ["sctx://_rule-pack"]
}Example override:
{
"id": "active-outcome-count",
"override": true,
"category": "workflow",
"description": "Require exactly one active outcome",
"global": true,
"expression": "$count(nodes[resolvedType='outcome' and status='active']) = 1"
}When a schema uses $metadata.hierarchy, rule evaluation cascades down the hierarchy, one level at a time from roots and orphans. A node becomes a gate when it fails in a way that makes its descendants' rules untrustworthy or wasteful to run. The nodes below a gate are gated: their rules are not evaluated.
A node becomes a gate when it has one of these:
- A schema validation error. The node's own rules are gated too, because its data is invalid.
- A hierarchy structure violation, such as an invalid parent. The node's own rules are gated too.
- A gating violation: a rule violation with
errorseverity, or any violation of a rule withgatesDescendants: true. The node's other expressions still run, but none of its semantic parts do.
Gating keeps costly checks, such as semantic rules, from running below a node that has errors. It also keeps the actionable violation from being lost in noise. A gated node does not start a new gate: everything below a gate is gated by that one node. Gating applies only to hierarchy nodes. Global rules and nodes outside the hierarchy are always evaluated.
A rule has one gatesDescendants setting. It applies to whichever part reported the violation, the expression or the semantic part. Its default comes from the severity of that violation: true for error, and false for warning and info. Set it explicitly when that default doesn't fit.
Each gated node gets a gated validation issue at info severity, so a gated node does not read as clean. --json output lists them under issues like every other finding; grouping them by gatedBy shows which nodes each gate suppressed.
Use --include-gated to evaluate rules on gated nodes anyway. They are still reported as gated. This is useful for cheap, bottom-up checks, such as content quality, that don't depend on an ancestor being valid.
sctx validate-file <path> applies the same gating as sctx validate, so a file's rule results match its entry in a full run.
- Expressions are evaluated for the whole space; the output is restricted to the file.
- Semantic parts are evaluated only for the file and its gating ancestors (see Semantic rules).
- If the file is gated, its rules are skipped and a
gated:<file>issue atinfoseverity names the gate, so a gated file doesn't read as clean.--include-gatedevaluates the file's rules anyway.
These options restrict which rules run, without changing rule definitions.
--rule-filter <dimension>=<value>[,<value>] runs only the rules that match. It works on sctx validate and sctx validate-file.
- Dimensions:
category,severity,typeandid. - Values in one dimension combine with OR. Repeat the option to add a dimension; dimensions combine with AND.
- A rule matches each severity that it can report, including the severities in a semantic verdict map.
- Gating comes only from the rules that ran.
For example, --rule-filter category=workflow,coherence --rule-filter severity=error runs only workflow and coherence rules that can report an error.
--depth <n> restricts validation to hierarchy nodes within n breadth-first levels from
the top (roots and orphans are depth 1). This is a manual, coarser control than cascade
gating — intended for staged workflows (e.g. an agent fixes the top level, then requests
the next). Excluded nodes are reported under skippedByDepth, separate from gated nodes.
sctx validate— the summary counts rule violations per severity, and details are grouped by severity with each line showing the rule's category and id.sctx validate --json— prints the run'sValidationResult, withspace,validanderrorCount. A violation from a failedexpressionhas the evidenceexpression failed: <expression>when the rule also has asemanticpart, and no verdict.sctx validate-file --json— prints the file's issues, witherrorCount.
Gated files: see Single-file validation.