Functional Models with Jsonnet
Rescile supports Jsonnet as a parallel, first-class authoring language for models (models/*.jsonnet), compliance (compliance/*.jsonnet), and outputs (output/*.jsonnet).
Jsonnet files evaluate into canonical Rescile rule structures at build time, executing alongside existing TOML rules in the property graph stabilization loop.
Why Jsonnet?
- Reusability: Define helper functions and shared configurations instead of repeating TOML blocks.
- Comprehensions: Generate rule sets from lists or data structures using
[rule(x) for x in list]. - Pure & Deterministic: Pure functional configuration evaluated within a confined sandbox.
- Gradual Adoption:
.jsonnetand.tomlfiles coexist seamlessly in the same directory.
Decision Rubric: Choosing Between TOML and Jsonnet
TOML and Jsonnet are equal first-class authoring formats in Rescile. Choose based on whether your rule structure benefits from functional programming:
| Pattern | Recommended Format | Rationale |
|---|---|---|
| Simple Static Singleton (e.g. creating 1 fixed boundary node) | TOML or Jsonnet | TOML offers a minimal, 5-line static declaration. Jsonnet is equally concise (rescile.createResource(...)). |
| Parameterized Rule Family (e.g. multiple rules differing by env/tier) | Jsonnet | Array comprehension [rule(x) for x in list] eliminates boilerplate. |
| Matrix Expansions (e.g. regions × tiers × clouds) | Jsonnet | Nested loops generate complete graph structures from compact configuration matrices, replacing dozens of repetitive TOML files. |
Global Input Iteration (e.g. creating nodes from input/data.json) |
Jsonnet | Native import 'data.json' and comprehension is cleaner than TOML create_from. Rules default to singleton (origin=''). |
| Global Aggregation & Pure Math | Jsonnet | Pure functions, math (CIDR calculations, sizing), and filtering in Jsonnet or lib/*.libsonnet without needing external script runtimes. |
| Multi-Standard Compliance Frameworks | Jsonnet | Generate repeated control targets across multiple regulations (CIS, SOC 2, ISO 27001) dynamically from structured catalogs. |
| Per-Resource Graph Traversal | TOML or Jsonnet | Use TOML for a single direct rule; use Jsonnet (origin='servers' or rescile.forOrigin('servers', [...])) when generating multiple variants or relations. |
Natural Defaults: Like TOML, all
rescile.libsonnetbuilders default to singleton execution (origin=''). To iterate over graph vertices, specifyorigin='servers'or wrap rules inrescile.forOrigin('servers', [...]).
Embedded Standard Library (rescile.libsonnet)
Rescile embeds a standard helper library accessible via virtual import:
local rescile = import 'rescile/v1/rescile.libsonnet';
Core Builders
| Helper | Description |
|---|---|
rescile.eq(property, value) |
Equality matcher { property: "...", value: "..." } |
rescile.notEq(property, value) |
Inequality matcher — emits the not operator { property: "...", not: "..." } |
rescile.neq(property, value) |
Alias of notEq. (Previously emitted an unsupported op key that the engine ignored, silently treating the condition as equality; it now maps to not.) |
rescile.and(rules) |
{ and: [...] } grouping — conditions (or nested groups) combined with AND. Accepts an array or a single rule. |
rescile.or(rules) |
{ or: [...] } grouping — conditions (or nested groups) combined with OR. Accepts an array or a single rule. |
rescile.inList(property, values) |
List membership matcher |
rescile.params |
Object containing all module parameters (std.extVar('params')) |
rescile.param(name, default=null) |
Read module parameter with optional default fallback |
rescile.forOrigin(originResource, rules) |
Scopes a rule or list of rules to an origin resource |
rescile.createFromList(listName, asName) |
create_from list expansion helper |
rescile.createFromProperty(propName, asName) |
create_from origin property expansion helper |
rescile.jsonInput(pathTemplate, jmespath) |
Root input binding (json!) — static paths load once, templated paths resolve per origin |
rescile.templateInput(tpl) |
Root input binding (template!) — header template variable |
rescile.functionInput(tpl) |
Alias of rescile.templateInput (function! spelling, TOML parity) |
rescile.externalInput(fetcher, args, jmespath) |
Root input binding (external!) — on-demand external fetcher (model files only) |
rescile.createResource(...) |
Resource creation rule (create_resource) |
rescile.linkResources(...) |
Resource link & property copy rule (link_resources) |
rescile.copyProperty(...) |
Property copy rule (copy_property) |
rescile.retypeRelation(...) |
Relation retyping rule (retype_relation) |
rescile.audit(...) |
Compliance audit definition |
rescile.control(...) |
Compliance control definition |
rescile.target(...) |
Compliance control target |
Reusable Libraries and the lib/ Directory
Store shared, reusable .libsonnet files in the dedicated lib/ directory.
my-project/
└── data/ (or module root)
├── lib/
│ ├── network_helpers.libsonnet <-- Reusable functions
│ └── common_tags.libsonnet
├── input/
│ └── providers.json
└── models/
└── vpc_network.jsonnet <-- Model rules
Auto-Discovery from lib/
Rescile’s import resolver automatically searches the lib/ directory. You can import libraries directly by filename without relative directory paths (../lib/).
.libsonnet imports follow the same shared lookup as lib/*.rhai: the roots are searched in workspace order — data_dir/lib first, then each module’s lib/ in module order. A model in any module can import a helper from the workspace data directory or from another module’s lib/. The same hierarchy applies everywhere rule files are evaluated: graph builds, validate (including --render-ir), the serve-time workspace audit, and the editor LSP (the LSP only knows the open file’s module, so it resolves module-local lib/ only). A later root with the same filename is shadowed and reported as a warning:
// models/vpc_network.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
local net = import 'network_helpers.libsonnet'; // <-- Auto-resolved from lib/
rescile.createResource(
origin='vpcs',
resourceType='subnet',
relationType='CONTAINS_SUBNET',
name=net.makeSubnetName('{{ origin_resource.name }}', 'public'),
properties={ cidr: net.calcCidr('10.0.0.0/16', 1) }
)
Resolution order for a lib/ import:
- A file with the same name next to the importing file (relative import) always wins — see Nested libsonnet imports below.
data_dir/lib/— the workspace can override any module helper.- Each module’s
lib/in module order — the file’s own module included, no special precedence.
Module order is dependency order for git modules: a module is listed after every module it depends on. For local modules passed with --module <path>, the CLI argument order applies.
When the same import name exists in several lib/ roots, the first match wins and the importer reports a
WARN ... shadowed matches line naming the shadowed copies — the same behavior as rhai lib() calls.
Sandboxing stays confined: imports can only read files inside lib/ roots (plus the module’s own directories).
Rhai lookup has no caller-relative step
lib(path="...", function="...") (and the equivalent match_on = [{ lib = { path = "...", function = "..." } }]) never resolves relative to the importing rule file. The path is looked up in the shared roots only: data_dir/lib/ first, then each module’s lib/ in module order. The same script resolves identically no matter which rule calls it.
One asymmetry to know: a import statement inside a .rhai file (the Rhai module system) resolves against data_dir/lib/ only — even when the importing .rhai file itself was resolved from a module’s lib/. Keep module-internal rhai self-contained or provide the shared modules in the workspace lib/.
read_input_json("file.json") inside a Rhai script reads workspace input files with the same root ordering (data_dir/input/ first, then each module’s input/) and the same shadow warnings.
Nested libsonnet imports (sibling-first)
When a .libsonnet imports another .libsonnet, the importing file’s own directory is checked before every lib/ root — including data_dir/lib/. This gives a libsonnet that was itself resolved from data_dir/lib/ the expected behavior:
# data/lib/provider.libsonnet — imports resolve data-dir first:
import 'aws.libsonnet' → data/lib/aws.libsonnet, else module lib/ in module order
But it also means a module-resident libsonnet prefers its own siblings, and a leaf override in the workspace does not reach it:
# module-a/lib/provider.libsonnet, with data/lib/aws.libsonnet ALSO existing:
import 'aws.libsonnet' → module-a/lib/aws.libsonnet (sibling wins, no warning)
Rule of thumb: to override a module helper, override the entry file you import (drop provider.libsonnet into data_dir/lib/). Then the entry file resolves its imports from data_dir/lib/ first, and the whole subtree uses workspace values. Overriding only a leaf (e.g. aws.libsonnet) redirects imports made from workspace-resident files, but module-resident siblings keep using their own copy.
Sibling-first wins are silent — the shadowed matches warning is only reported when a shared lib/ root wins over another root.
Purpose of lib/
- Two library types: Jsonnet imports reusable
lib/*.libsonnetfiles; Tera templates in Jsonnet or TOML models calllib/*.rhaiwithlib(path="...rhai", function="..."). CSV assets and action runtimes do not execute these libraries. Both library types share the same workspace-widelib/roots and shadow warning. - Reusable functions: Keep static Jsonnet calculations in
.libsonnetand runtime Tera policy or transformations in.rhai. - Keep Models Clean: Models in
models/*.jsonnetfocus on declarative graph bindings (createResource,linkResources) while delegating complex calculations tolib/.
Setting origin_resource to Iterate Over Graph Vertices
Every model rule specifies which graph resources it consumes:
1. Iterating Over Graph Vertices
Setting origin='servers' evaluates the rule once for every servers vertex in the graph. In template strings, {{ origin_resource.property }} accesses the properties of the current vertex:
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.createResource(
origin='servers', // <-- Iterates over every 'servers' vertex in graph
resourceType='active_server',
relationType='IS_ACTIVE',
name='{{ origin_resource.name }}-active',
properties={
ip: '{{ origin_resource.ip_address }}',
status: 'active',
},
match=[rescile.eq('status', 'online')]
)
2. Global / Singleton Execution (No Graph Iteration)
Setting origin='' (empty string, which is also the default) executes the rule once globally without binding to an origin vertex:
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.createResource(
origin='', // <-- Runs once globally (default)
resourceType='security_boundary',
name='global-perimeter',
properties={ level: 'pci-dss' }
)
Singletons do not require
relationType: Because a singleton has no origin vertex, no relation edge can be created.relationTypeis optional and can be omitted whenorigin=''(or whenoriginis omitted).
3. Multiple origin_resource Blocks in One File
In TOML, files are split at origin_resource = "..." boundaries. In Jsonnet, simply return an array of model rule objects targeting different origins:
local rescile = import 'rescile/v1/rescile.libsonnet';
[
// Iterates over all 'servers'
rescile.createResource(
origin='servers',
resourceType='backup_job',
relationType='HAS_BACKUP',
name='backup-{{ origin_resource.name }}'
),
// Iterates over all 'databases'
rescile.createResource(
origin='databases',
resourceType='backup_job',
relationType='HAS_BACKUP',
name='backup-{{ origin_resource.name }}'
),
]
4. Scoping Rule Groups with rescile.forOrigin
Instead of repeating origin='servers' in every rule call, wrap a group of rules in rescile.forOrigin:
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.forOrigin('servers', [
rescile.createResource(
resourceType='active_server',
relationType='IS_ACTIVE',
name='{{ origin_resource.name }}-active',
),
rescile.linkResources(
withResource='clusters',
joinLocal='cluster_id',
joinRemote='id',
relationType='PART_OF',
),
])
Using Tera Templates and Custom Filters in Generated Rules
Rescile evaluates models in two distinct phases:
- Load Time (Jsonnet): Jsonnet evaluates functions, list comprehensions, and configurations, emitting canonical rule definitions. When creation-rule
nameorpropertiesread nativeorigin_resource, Jsonnet also re-evaluates those fields for each origin without changing rule structure. - Runtime (Tera & Graph Engine): The importer applies generated rules across vertices, rendering quoted
{{ ... }}strings with the Tera template engine. Rendering a string does not update the Jsonnet expression that emitted it.
Any string value in name, properties, or match_on can use the full power of Tera expressions and Rescile’s custom filters.
1. CIDR Calculations and Subnetting
Rescile’s custom CIDR filters are fully available inside template strings emitted by Jsonnet:
// models/network_topology.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.createResource(
origin='vpcs',
resourceType='subnet',
relationType='CONTAINS_SUBNET',
// Calculate specific nth /24 subnet from origin VPC CIDR:
name='subnet-{{ origin_resource.name }}-{{ origin_resource.cidr | cidr_nth_subnet(prefix=24, nth=2) }}',
properties={
// Subnet calculation filter executed dynamically per vertex:
allocated_cidr: '{{ origin_resource.cidr | cidr_nth_subnet(prefix=24, nth=2) }}',
// Split origin CIDR into subnets:
all_subnets: '{{ origin_resource.cidr | cidr_split_n(n=4) }}',
}
)
Available CIDR filters in templates:
cidr_nth_subnet(prefix=N, nth=M)— Extracts the M-th subnet of size /N.cidr_split_n(n=N)— Divides a CIDR into N equal subnets.calculate_cidr(ips=[...])— Computes the smallest enclosing CIDR.allocate_subnets(cidr="...", host_map={...})— Allocates subnets sized by required host counts.
2. Cryptographic and Hashing Filters
// models/secure_tokens.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.createResource(
origin='services',
resourceType='service_identity',
relationType='IDENTIFIED_BY',
name='ident-{{ origin_resource.name }}',
properties={
// Deterministic SHA-256 hash of service name and environment:
token_hash: '{{ (origin_resource.name ~ "-" ~ origin_resource.env) | sha256 }}',
// Base64 encoding:
encoded_id: '{{ origin_resource.name | base64_encode }}',
}
)
3. Tera filters versus native Jsonnet functions
Both approaches can compute a property for each origin. They run at different times:
| Approach | Source of value | When it runs |
|---|---|---|
| Quoted Tera expression, such as `’{{ origin_resource.name | upper }}'` | Current graph vertex in the Tera context |
Native Jsonnet expression, such as std.asciiUpper(origin_resource.name) |
std.extVar('origin_resource') |
When Jsonnet re-evaluates the creation rule for each origin |
Use Tera filters when you want to transform a graph value inside a template. The Jsonnet local below holds template text, not the rendered name:
local rescile = import 'rescile/v1/rescile.libsonnet';
local myString = '{{ origin_resource.name | lower }}';
rescile.createResource(
origin='zone',
resourceType='zone_result',
name='{{ origin_resource.name }}-tera',
properties={
lowerResult: myString,
uppercaseResult: '{{ origin_resource.name | upper }}',
},
)
Use native Jsonnet functions when you want Jsonnet to operate on the origin value directly. Rescile first validates this rule with a mock origin, then re-evaluates its name and properties for each real zone:
local rescile = import 'rescile/v1/rescile.libsonnet';
local origin_resource = std.extVar('origin_resource');
rescile.createResource(
origin='zone',
resourceType='zone_result',
name=origin_resource.name + '-jsonnet',
properties={
uppercaseResult: std.asciiUpper(origin_resource.name),
},
)
std.asciiUpper changes ASCII letters only. Tera’s upper filter and Jsonnet’s std.asciiUpper are not interchangeable for every Unicode string. Do not write uppercaseResult: {{ origin_resource.name | upper }}: unquoted Tera is invalid Jsonnet. Do not pass myString from the first example to std.asciiUpper: Jsonnet would uppercase the literal {{ ... }} expression before Tera sees it, not the rendered name. Put these per-origin calculations in create_resource.name or properties, not a shared root field. Native per-origin Jsonnet cannot change rule structure; see Native Jsonnet expressions per origin.
Iterating Over Input JSON and Lists (create_from)
Rescile supports two complementary patterns for iterating over data lists:
Pattern A: Native Jsonnet Load-Time Comprehension (Recommended)
Import JSON files or declare lists directly in Jsonnet. Rescile automatically includes the input/ and lib/ directories in the search path (just like TOML’s "json!" = "filename.json"):
// models/cloud_providers.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
// Auto-resolved from input/ directory:
local providers = import 'providers.json';
// 2. Expand into one rule per item:
[
rescile.createResource(
origin='', // Global execution
resourceType='cloud_provider',
relationType='DEFINES',
name='provider-' + p.name,
properties={ region: p.region, enabled: p.enabled }
)
for p in providers
]
Pattern B: Runtime create_from Origin Property Expansion
When an origin_resource has a comma-separated or array property (e.g. services = "ui,api,worker"), generate one resource per value at graph runtime using createFromProperty:
// models/app_services.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.createResource(
origin='applications', // Iterates over each application
createFrom=rescile.createFromProperty('services', asName='service'),
resourceType='service',
relationType='PROVIDES',
name='svc-{{ value | upper }}', // 'value' is each comma-separated entry
properties={
application: '{{ origin_resource.name }}',
service_index: '{{ property.index }}', // 0-based iteration index (0, 1, 2, ...)
}
)
Iteration Variables: Inside a
createFromPropertyiteration, the engine injects:
valueorproperty.value: the string value of the current itemproperty.index: the 0-based iteration index integer (0,1,2, …)origin_resource: the parent vertex being expanded(Note: Tera’s
loop.index0orloop.indexis not defined here because iteration runs inside the Rust engine, not inside a Tera{% for %}loop).
Variable Scoping and Lifecycle: local vs. Model Globals vs. Runtime Tera
When a model combines Jsonnet and expressions such as {{ origin_resource.name }}, choose where to put a value based on who should share it:
| What you need | Put it here | What it refers to |
|---|---|---|
| A reusable constant or expression | Jsonnet local |
The same text or static value wherever you use it |
| A value available throughout this model | A top-level field (+ { ... }) |
A shared value; origin_resource here refers to the set of resources matching origin |
| A value that differs by origin resource or list item | name or properties |
The current origin_resource; inside createFromProperty, also value and property.index |
Reuse a constant or expression with local
DO
-
Use
localfor constants and imported helpers:local subnet_count = 4;orlocal aws = import 'aws.libsonnet';.Impact: You can reuse them in Jsonnet fields, such as
n=' + subnet_count + 'within a quoted expression oraws.decode.networkin a name. -
Reuse a quoted expression:
local parent = '{{ origin_resource.name | upper }}';followed byname=parent + '-{{ value }}'.Impact: Each generated resource gets the name of its own origin resource; the
localonly holds the expression, not a precomputed name. -
Import static lookup data into a
localwhen the key is known in Jsonnet, for examplelocal locations = import 'location_codes.json';andlocations['Zurich'].iata.Impact: Jsonnet can select that entry while defining the model. A
localis not automatically available in Tera. To look up a key from the currentvalueororigin_resource, export the imported data as a top-level field and use a quoted lookup instead (see Value Lookup from Input JSON).
DON’T
-
Write
name='{{ parent }}-{{ value }}'expecting it to uselocal parent.Impact: A Jsonnet
local(including an imported helper) is not a variable inside{{ ... }}; the lookup may fail or resolve a different top-level field namedparent. -
Write
local segments = '{{ origin_resource.cidr | lib(...) }}';and thencidr='{{ segments[property.index] }}'.Impact:
segmentsis still just quoted text in Jsonnet, not a list available to the later expression. Calculate and index the list in the samepropertiesfield instead.
local rescile = import 'rescile/v1/rescile.libsonnet';
local parent = '{{ origin_resource.name | upper }}';
rescile.createResource(
origin='router',
createFrom=rescile.createFromProperty('network', asName='network'),
resourceType='network',
relationType='DEPENDS_ON',
name=parent + '-{{ value }}', // DO: insert the expression into this field
properties={ parent: parent }
)
// DON'T: name='{{ parent }}-{{ value }}' (parent is not a template variable)
Share a value across a model with a top-level field
DO
-
Expose shared values with an explicit key:
local parent = 'SHARED'; ... + { parent: parent }.Impact:
{{ parent }}refers toSHAREDfor every resource produced by this model. -
Use a top-level expression for a set-wide value: with
origin='router',+ { total_routers: '{{ origin_resource | length }}' }.Impact: Every generated resource can use
{{ total_routers }}to see the count of matching routers. Static reference data can also be exposed here, for example+ { locations: import 'location_codes.json' }for{{ locations[value].iata }}. -
Use
origin_resource[0]only if the first resource is intentionally authoritative, for example when all accounts must share a CIDR plan.Impact: The partition remains shared, but adding or renaming resources can change which one is first (matching resources are ordered by name). Document the choice and prefer an explicit selection if possible.
DON’T
-
Use
+ { parent }to expose alocal.Impact: Jsonnet does not support this shorthand; write
+ { parent: parent }instead. -
Move a per-resource expression into a top-level field:
local parent = '{{ origin_resource.name | upper }}'; ... + { parent: parent }.Impact: Here
origin_resourceis the set of matching resources, not the current one. Rescile warns about this shared-field pattern.{{ parent }}will not become each resource’s name; in a two-router test it produced an empty parent and both resources targeted the same-netname. Usename=parent + '-{{ value }}'orproperties={ parent: parent }instead. -
Use
+ { cidr: '{{ origin_resource[0].cidr }}' }when each router needs its own CIDR.Impact: Every generated resource receives the first router’s CIDR, which can create incorrect subnet assignments; using
[0]for a name can also cause duplicate names.
local rescile = import 'rescile/v1/rescile.libsonnet';
local parent = 'SHARED';
rescile.createResource(
origin='router',
resourceType='network',
relationType='DEPENDS_ON',
name='{{ parent }}-{{ origin_resource.name }}',
properties={ router_count: '{{ total_routers }}' },
) + {
parent: parent, // Makes the static local available as {{ parent }}
total_routers: '{{ origin_resource | length }}',
}
Match on a shared policy without embedding it in Tera
A top-level field can calculate a decision from all resources matching origin. Put multi-resource policy in a Rhai library, then combine its result with a property check on the current resource. For example, create a resolver for every public firewall, and for private firewalls only when at least one private firewall allows inbound DNS:
// models/resolver.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.createResource(
origin='firewall',
resourceType='resolver',
relationType='DEFINED_BY',
name='{{ origin_resource.name }}-dns',
properties={
// Properties render for the current firewall, not the full set.
ports: '{{- [rule.port for rule in origin_resource.filter] | unique | json_encode -}}',
},
match=[{
'or': [
[rescile.eq('class', 'public')],
[rescile.matchExpression('{{ internal_dns_allowed }}')],
],
}],
) + {
// Root fields render once with origin_resource = all matching firewalls.
internal_dns_allowed: '{{- origin_resource | lib(path="dns.rhai", function="any_internal_dns") -}}',
}
// lib/dns.rhai
fn any_internal_dns(firewalls, args) {
for firewall in firewalls {
if firewall.class != "private" { continue; }
for rule in firewall.filter {
if rule.port == 53
&& rule.enable
&& rule.default_action == "allow"
&& rule.function == "ingress" {
return true;
}
}
}
false
}
lib(...) passes the list of firewalls as Rhai value; Rhai returns a boolean. The importer makes that rendered root value available as internal_dns_allowed when it evaluates each rule’s match. rescile.eq handles the public branch without an expression; matchExpression reads the shared result. The matcher renders the expression to true or false, then accepts only true.
Scope matters: If any private firewall passes the Rhai check, every private firewall matches this rule. This pattern does not restrict each private resolver to its own firewall’s DNS rules. ports remains per-firewall because it lives in properties. Do not put origin_resource.filter in the root field: there origin_resource is a list. Nor can a Jsonnet local read the Rhai result: Tera renders after Jsonnet evaluation.
This example assumes rule objects contain port, enable, default_action, and function. For a one-line local check, prefer a direct matcher; use Rhai when shared policy has multiple conditions or will evolve.
Native Jsonnet expressions per origin
Jsonnet expressions that read origin_resource are evaluated again for each matching resource. This applies to plain create_resource rules and to origin-scoped createFromList / createFromProperty rules alike: an origin-scoped createFromList rule re-evaluates the whole file for each matching origin and then once per list item, so name and properties can compute from real origin values in Jsonnet, not only in Tera. In create_resource, only name and properties may vary by origin. Keep origin, resource_type, match_on, create_from, and relation fields stable. Rescile fails the graph build if the rule structure changes or a per-origin evaluation fails; it does not silently use the load-time value.
The exception is a create_from list that is not scoped per origin (origin='', or a dynamic/external list that has no origin scope): it runs once globally, so Jsonnet expressions keep their load-time mock values — the importer warns about this and only Tera in name/properties can vary per item.
If multiple generated creation rules have otherwise identical structure and their names or properties vary, give each rule a distinct, stable id (for example, rescile.createResource(id='primary', ...)). IDs identify rules, not graph resources; do not derive them from origin_resource. Rule order and structure must remain stable across origins. Verify the built graph with at least two distinct origins: --render-ir only shows the load-time representation.
The origin_resource Ext Var: Phases, Shape, and Safe Access
How origin data reaches Jsonnet. Per-origin values enter native Jsonnet only through the external variable std.extVar('origin_resource'). Bind it once at the top of the file; if the binding is missing, rescile injects local origin_resource = std.extVar('origin_resource'); automatically before evaluation. The variable holds the current origin vertex’s flat property map — nothing else:
- Plain properties keep their JSON types (
name, strings, numbers, arrays). - A column that follows the foreign-key convention and linked successfully contains the linked target objects, not the raw primary key: a
providercolumn plus an existingprovider.csvevaluates to[{"id": "<uuid>", "name": "aws", "original_name": "aws"}]— the same shadow-fix replacement the graph uses for automatic relations. idandlabelof the origin vertex itself are not part of the map; only genuine properties are.- Relations created by
linkResources(explicit joins) are not visible in the ext var. TOML’s Tera traversal ({{ origin_resource.provider[0].name }}resolves any relation, up to a depth limit) has no native-Jsonnet equivalent. To read relation data, use a quoted Tera expression inname/properties, or copy the needed value into a plain property withcopyPropertyfirst.
Two-phase evaluation and the mock. Every Jsonnet model is evaluated twice:
- Load time — once with a mock origin for structure validation and dependency analysis. Every field the file references is the string
"mock"; all mock values are strings, never arrays. - Per origin — the whole file is re-evaluated once per matching vertex with the real property map. Only
nameandpropertiesmay differ from the load-time shape; any other structural change fails the build.
Because the mock is all strings but runtime foreign-key values are arrays, direct indexing passes one phase and fails the other — both are hard errors that stop the build:
| Expression | Load time (mock) | Per origin (real FK) |
|---|---|---|
std.asciiUpper(origin_resource.provider) |
works (string "mock") |
error — linked FKs are arrays |
origin_resource.provider[0].name |
error — string cannot be indexed by number | works (array) |
Safe access pattern — dispatch on the type so both phases succeed:
local origin_resource = std.extVar('origin_resource');
// FK columns are arrays of target objects at runtime, plain strings in the
// load-time mock. Dispatch on the type so both evaluation phases succeed.
local pf = origin_resource.provider;
local providerName = if std.isArray(pf) then pf[0].name else pf;
local provider = providerLib(std.asciiUpper(providerName));
Use providerName inside name or properties. The library call may vary values only — it must not change rule structure per origin (which rules exist, resource_type, match_on).
Libraries selected by an origin value must survive the mock. The dispatch above typically feeds a name-keyed library — providerLib(providerName) returning one of several provider objects, with an empty fallback for unknown names. The load-time mock hits that fallback (provider is "mock"), so:
- The fallback object must be structurally complete: every field any model touches (
decode.account,portfolio,roles, …) must exist on it, or the build fails at load time withno such field. Placeholder values are fine — per-origin rules discard them — but the shape must be present. A self-mapping placeholder keeps this cheap:decode: { [t]: t for t in ['account', 'compute', 'network', ...] }. Extend the list whenever models start accessing a new key. - Do not fall back to a real provider object for unknown names: that masks the failure at load time, and a singleton rule would silently bake the fallback provider’s content into the graph.
- A key that is provider-specific rather than universal is better guarded at the access site:
std.get(provider.decode, 'account', default='account').
Also remember Jsonnet imports are lazy: import 'azure.libsonnet' inside an unused branch of the library only fails when that branch is actually taken — a missing library file surfaces as a hard error at first use, not at load.
Why not merge origin into the rule? rescile.createResource(...) + { origin = std.extVar('origin_resource') } does not bind origin data. Unknown root fields are classified as header input bindings: the field silently becomes a Tera context input named origin, never origin data. The origin= parameter of rescile.createResource() is a different thing — the origin type name (for example origin='servers') that scopes the rule; the instance data is only the ext var.
Sharing values between files. There is no Jsonnet-to-Jsonnet in-memory channel between models, and plain jsonnet --ext-str has no cross-process memory either. Use one of:
import 'provider.libsonnet'+ function call — pass per-origin values as arguments (the pattern above).data/input/*.json— materialized shared data, imported directly (import 'providers.json'; the input directory is in the Jsonnet search path) or bound withrescile.jsonInput(...).--module-params key=value→rescile.param('key', default)— static for the whole run.- The graph itself — a parent model writes a property, a child model with a matching
origin=reads it via the ext var; model ordering and the stabilization loop make the value available.
Compute a value for each resource or list item
DO
-
Put per-resource expressions in
nameorproperties, for exampleproperties={ cidr: '{{ origin_resource.cidr }}' }.Impact: Two routers with different CIDRs produce different values, rather than copying one router’s CIDR to both.
-
Use
property.indexwithcreateFromPropertyto select the matching item:cidr: '{{ origin_resource.cidr | lib(path="network.rhai", function="cidr_split_n", n=4) | nth(n=property.index) }}'.Impact: Item 0 receives the first subnet, item 1 the second, and so on, for each router independently. Ensure the split yields at least as many items as the property contains.
DON’T
-
Use
loop.index0here:cidr: '{{ segments[loop.index0] }}'.Impact:
createFromPropertydoes not create a Tera{% for %}loop, soloopis unavailable; useproperty.indexinstead. Rescile caches identicallib(...)calls, so you do not need a top-level field just to avoid repeating the Rhai calculation.
For example, split each router’s CIDR across its networks:
Select a subnet with nth
Use nth(n=property.index) to select the subnet for the current network from the list returned by lib(...):
local rescile = import 'rescile/v1/rescile.libsonnet';
local parent = '{{- origin_resource.name | regexp(expr="s/^([^-]+-[^-]+)-.*/\\1/") | lower -}}';
local subnet_count = 4;
rescile.createResource(
origin='router',
createFrom=rescile.createFromProperty('network', asName='network'),
resourceType='network',
relationType='DEPENDS_ON',
name=parent + '-{{- value | lower -}}',
properties={
cidr: '{{ origin_resource.cidr | lib(path="network.rhai", function="cidr_split_n", n=' + subnet_count + ') | nth(n=property.index) }}',
}
)
Syntax note: In Tera, filter arguments use parenthesis and key-value naming:
| nth(n=property.index). Do not write| nth=[...].
Alternatively, use {% set %} within the field
properties={
cidr: '{%- set segments = origin_resource.cidr | lib(path="network.rhai", function="cidr_split_n", n=' + subnet_count + ') -%}{{- segments[property.index] -}}',
}
Rescile caches identical lib(...) calls, so repeated list items with the same CIDR and arguments can reuse the result. The expression is still evaluated for each item; cache entries are not guaranteed to remain available.
If your Rhai helper returns just one subnet instead of a list, pass property.index to that helper instead:
properties={
cidr: '{{ origin_resource.cidr | lib(path="network.rhai", function="nth_subnet", n=4, nth=property.index) }}',
}
Pattern C: Runtime create_from Header / Dynamic Input List
When iterating over an input list loaded by the importer engine:
// models/regions.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
// 1. Declare the list field in the model object:
{
region_list: ['eu-west-1', 'eu-central-1', 'us-east-1'],
} + rescile.createResource(
origin='',
createFrom=rescile.createFromList('region_list'),
resourceType='cloud_region',
relationType='REGION_IN',
name='region-{{ value }}'
)
With origin='' the list iterates once globally; origin_resource is unavailable and relation_type is ignored.
When the same exported static list is combined with a non-empty origin, rescile runs a nested loop — once per
matching origin resource, then once per list item. origin_resource is available in templates, match filters the
origins, and relation_type links each created resource to its origin. Jsonnet expressions that read origin_resource
are also re-evaluated per matching origin, so name and properties may compute from real origin values in Jsonnet
itself — for example local class = std.asciiLower(origin_resource.class); yields the actual class, not the load-time
mock. Use Tera ({{ origin_resource.* }}) and native Jsonnet interchangeably here; both see the same origin:
// models/compute_instances.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
{
instances: ['test', 'dev'],
} + rescile.createResource(
origin='service',
createFrom=rescile.createFromList('instances'),
resourceType='compute',
relationType='DEFINED_BY',
name='{{ origin_resource.name }}-{{ value | lower }}-instance',
match=[rescile.eq('class', 'compute')],
)
Pattern C2: Per-Origin Input File Selection (rescile.jsonInput)
Jsonnet cannot template import paths (import takes a literal string only), but rescile.jsonInput(...) root
bindings provide the TOML json! equivalent: an origin-dependent path template is resolved once per matching origin
at runtime, with the result cached per distinct path. Combine with rescile.templateInput(...) to derive the path
from origin properties:
// models/provider_logins.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
{
environment_name: rescile.templateInput('{{ origin_resource.environment | lower }}'),
providers: rescile.jsonInput('{{ environment_name }}-providers.json', jmespath='providers'),
} + rescile.createResource(
origin='compute',
createFrom=rescile.createFromList('providers'),
resourceType='login',
relationType='USES_PROVIDER',
name='{{ origin_resource.name }}-{{ value.name }}-login',
)
- Static path (no templates): the JSON file loads once at parse time, like a literal TOML
json!header. - Templated path: dynamic binding — origin-dependent paths resolve per origin, global paths once;
create_from.listscope follows the same inference. The path may not depend onvalue. rescile.externalInput(fetcherName, args, jmespath)declares anexternal!binding the same way; templatedjson!paths andexternal!bindings are model-file-only (rejected in compliance/output files).
Dynamic json! lists keep the binding-based scope (global dependencies run once, origin_resource dependencies
run per origin) — regardless of whether they are declared in a TOML model header or through a Jsonnet
rescile.jsonInput(...) root binding. A plain Jsonnet root export (a literal array or string) is a static list and
follows the origin rule above.
Pattern D: Value Lookup from Input JSON (without Creating Graph Nodes)
When reference data (such as airport codes, ISO country codes, or IP subnets) is delivered as JSON in input/, you can look up values and attach them directly to generated vertices without turning the reference dataset into graph nodes:
Approach 1: Export to Tera Context + Quoted Bracket Lookup
Export the imported JSON in the top-level model object so it is available in Tera. Importing it into a Jsonnet local alone does not export it. Quote the lookup expression so Tera resolves it dynamically at runtime per iterated vertex:
// models/regions.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
local parent = '{{- origin_resource.name | regexp(expr="s/^([^-]+-[^-]+-[^-]+)-.*/\\1/") | upper -}}';
rescile.createResource(
origin='account',
createFrom=rescile.createFromProperty('regions', asName='region'),
resourceType='region',
relationType='DEFINED_BY',
name=parent + '-{{ value | trim }}',
properties={
location: '{{ value | trim }}',
// Quoted Tera expression: evaluated dynamically per iterated value at runtime
iata: '{{ locations[value | trim].iata | default(value="") }}',
},
) + {
// Exported top-level key: loader injects this into Tera context for this model
locations: import 'location_codes.json',
}
Multiple rules: When exporting values for an array of rules, return an envelope such as
{ locations: import 'location_codes.json', models: [rule1, rule2] }. A bare array has no root fields to export. The same pattern works withauditsandoutputsin compliance and output files.Evaluation Timing Note: Do not write
iata: locations['{{ value }}'].iataas unquoted Jsonnet code. Jsonnet executes once at parse time before any graph vertices exist. Writing unquoted code looks up the literal string"{{ value }}"in the Jsonnet dictionary at compile time and fails (no such field: {{ value }}). Always wrap the lookup inside a quoted Tera string'{{ locations[value | trim].iata }}'.
Approach 2: Procedural Rhai Library with read_input_json
For larger datasets (such as a 2MB airport database), exporting into the Tera context can cause memory overhead because the data map enters every vertex’s template context. Instead, use a Rhai script in lib/ with the sandboxed read_input_json helper:
- Define the lookup helper in
lib/locations.rhai:
fn get_iata(city, _args) {
// Reads from <module-root>/input/location_codes.json; cached in memory after first call
let locations = read_input_json("location_codes.json");
let entry = locations[city];
if entry != () {
return entry.iata;
}
return "";
}
- Invoke the Rhai filter from the Jsonnet model properties:
// models/regions.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
local parent = '{{- origin_resource.name | regexp(expr="s/^([^-]+-[^-]+-[^-]+)-.*/\\1/") | upper -}}';
rescile.createResource(
origin='account',
createFrom=rescile.createFromProperty('regions', asName='region'),
resourceType='region',
relationType='DEFINED_BY',
name=parent + '-{{ value | trim }}',
properties={
location: '{{ value | trim }}',
// Rhai filter executes per vertex with in-memory cached lookup
iata: '{{ value | trim | lib(path="locations.rhai", function="get_iata") }}',
},
)
Linking and Foreign Key Join (linkResources)
// models/application_ownership.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.linkResources(
origin='applications', // Origin resource
withResource='teams', // Remote resource to link with
joinLocal='owner_team', // Local key on application
joinRemote='name', // Remote key on team
relationType='OWNED_BY',
direction='local_to_remote',
copyProperties=[
{ from: 'lead', as: 'team_lead' },
{ from: 'slack_channel', as: 'contact_channel' },
]
)
Accessing Module Parameters (params) in Jsonnet
Module parameters configured via --module-params key=value or declared in module.toml are accessible in Jsonnet in several ways:
1. The rescile.params and rescile.param() Helpers (Recommended)
rescile.libsonnet exposes convenient accessors for module parameters:
local rescile = import 'rescile/v1/rescile.libsonnet';
// 1. Direct field access via rescile.params:
local env = rescile.params.environment;
// 2. Safe lookup with default fallback via rescile.param(name, default=null):
local region = rescile.param('region', 'eu-central-1');
local clusterSize = rescile.param('cluster_size', 3);
rescile.createResource(
origin='',
resourceType='cluster_config',
name='config-' + env,
properties={
environment: env,
region: region,
size: clusterSize,
}
)
2. Native Jsonnet std.extVar
Module parameters are injected into the Jsonnet evaluator as both top-level external variables and as a params object:
// Via the 'params' object:
local env = std.extVar('params').environment;
// Directly by parameter name:
local envDirect = std.extVar('environment');
3. Downstream Tera Template Interpolation
Jsonnet rules emit property strings that are evaluated at graph build time by the Tera engine. Rescile ensures module parameters are available in the Tera rendering context as {{ params.<name> }}:
local rescile = import 'rescile/v1/rescile.libsonnet';
rescile.createResource(
origin='servers',
resourceType='server_tag',
relationType='TAGGED',
name='{{ origin_resource.name }}-tag',
properties={
// Resolved by Tera during graph build:
env_label: '{{ params.environment }}',
}
)
Complex Architecture Example: Multi-Tier Network & Security Matrix
Here is an example that would require dozens of repetitive tables in TOML, but is expressed cleanly in Jsonnet using lib/.
1. Library: lib/network_matrix.libsonnet
Defines tiers, default flows, and generates subnets and firewall rules:
// lib/network_matrix.libsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
{
tiers:: [
{ name: 'public', subnetNth: 1, publicAccess: true },
{ name: 'private', subnetNth: 2, publicAccess: false },
{ name: 'database', subnetNth: 3, publicAccess: false },
],
flows:: [
{ from: 'public', to: 'private', port: 443, proto: 'tcp' },
{ from: 'private', to: 'database', port: 5432, proto: 'tcp' },
],
makeSubnetRules():: [
rescile.createResource(
resourceType='subnet',
relationType='CONTAINS_SUBNET',
name='subnet-{{ origin_resource.name }}-' + tier.name,
properties={
tier: tier.name,
cidr: '{{ origin_resource.cidr | cidr_nth_subnet(prefix=24, nth=' + tier.subnetNth + ') }}',
public: tier.publicAccess,
}
)
for tier in $.tiers
],
makeFlowRules():: [
rescile.createResource(
resourceType='firewall_rule',
relationType='ENFORCES_RULE',
name='fw-{{ origin_resource.name }}-' + flow.from + '-to-' + flow.to,
properties={
source_tier: flow.from,
target_tier: flow.to,
port: flow.port,
protocol: flow.proto,
action: 'allow',
}
)
for flow in $.flows
],
}
2. Model: models/vpc_topology.jsonnet
The actual model file requires only 4 lines of code:
// models/vpc_topology.jsonnet
local rescile = import 'rescile/v1/rescile.libsonnet';
local net = import 'network_matrix.libsonnet';
rescile.forOrigin('vpcs', net.makeSubnetRules() + net.makeFlowRules())
Sandbox & Confinement Rules
To ensure reproducible, safe evaluation:
- Path Confinement: Imports are restricted to the local module root and declared module dependencies (
import 'lib/utils.libsonnet'). - No Traversal Escape: Attempts to import outside the module root (
../../...) fail. - No Absolute Paths: Absolute file imports are rejected.
- Deterministic Limits: Evaluator enforces recursion depth caps and execution timeouts.
- No Ambient I/O: Jsonnet cannot execute shell processes, read environment variables, or perform network requests.
CLI Validation and Inspection
Render Intermediate Representation (IR)
Validate and render normalized rule structures as JSON. Rendering uses the same production loaders as graph imports, so TOML chunking, Jsonnet imports, module parameters, and semantic checks match normal execution.
# Render every model, compliance rule, and output definition
rescile-ce validate --render-ir
# Render one file as a ModelSet, ComplianceSet, or OutputSet envelope
rescile-ce validate --render-ir --target models/service_profiles.jsonnet
# Write workspace IR to a file
rescile-ce validate --render-ir --output workspace.ir.json
# Inspect the Canonical Rule IR JSON Schema
rescile-ce validate --render-ir-schema
IR modes always emit JSON. Diagnostics go to stderr, which keeps stdout safe for shell pipelines.
Validate Rules
Validate TOML and Jsonnet rules against syntax, sandbox, shape, and semantic constraints:
rescile-ce validate --only rules
Run full static workspace validation with bare rescile-ce validate. Add --build to construct and validate the graph in memory. Graph validation does not run data generators; materialize required generator targets first.