Architectural Models

Functional Models with Jsonnet

Author declarative, reusable, and parameterized graph rules using Jsonnet.

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: .jsonnet and .toml files 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.libsonnet builders default to singleton execution (origin=''). To iterate over graph vertices, specify origin='servers' or wrap rules in rescile.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:

  1. A file with the same name next to the importing file (relative import) always wins — see Nested libsonnet imports below.
  2. data_dir/lib/ — the workspace can override any module helper.
  3. 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/*.libsonnet files; Tera templates in Jsonnet or TOML models call lib/*.rhai with lib(path="...rhai", function="..."). CSV assets and action runtimes do not execute these libraries. Both library types share the same workspace-wide lib/ roots and shadow warning.
  • Reusable functions: Keep static Jsonnet calculations in .libsonnet and runtime Tera policy or transformations in .rhai.
  • Keep Models Clean: Models in models/*.jsonnet focus on declarative graph bindings (createResource, linkResources) while delegating complex calculations to lib/.

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. relationType is optional and can be omitted when origin='' (or when origin is 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:

  1. Load Time (Jsonnet): Jsonnet evaluates functions, list comprehensions, and configurations, emitting canonical rule definitions. When creation-rule name or properties read native origin_resource, Jsonnet also re-evaluates those fields for each origin without changing rule structure.
  2. 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:

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 createFromProperty iteration, the engine injects:

  • value or property.value: the string value of the current item
  • property.index: the 0-based iteration index integer (0, 1, 2, …)
  • origin_resource: the parent vertex being expanded

(Note: Tera’s loop.index0 or loop.index is 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 local for constants and imported helpers: local subnet_count = 4; or local aws = import 'aws.libsonnet';.

    Impact: You can reuse them in Jsonnet fields, such as n=' + subnet_count + ' within a quoted expression or aws.decode.network in a name.

  • Reuse a quoted expression: local parent = '{{ origin_resource.name | upper }}'; followed by name=parent + '-{{ value }}'.

    Impact: Each generated resource gets the name of its own origin resource; the local only holds the expression, not a precomputed name.

  • Import static lookup data into a local when the key is known in Jsonnet, for example local locations = import 'location_codes.json'; and locations['Zurich'].iata.

    Impact: Jsonnet can select that entry while defining the model. A local is not automatically available in Tera. To look up a key from the current value or origin_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 use local 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 named parent.

  • Write local segments = '{{ origin_resource.cidr | lib(...) }}'; and then cidr='{{ segments[property.index] }}'.

    Impact: segments is still just quoted text in Jsonnet, not a list available to the later expression. Calculate and index the list in the same properties field 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 to SHARED for 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 a local.

    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_resource is 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 -net name. Use name=parent + '-{{ value }}' or properties={ 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 provider column plus an existing provider.csv evaluates to [{"id": "<uuid>", "name": "aws", "original_name": "aws"}] — the same shadow-fix replacement the graph uses for automatic relations.
  • id and label of 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 in name/properties, or copy the needed value into a plain property with copyProperty first.

Two-phase evaluation and the mock. Every Jsonnet model is evaluated twice:

  1. 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.
  2. Per origin — the whole file is re-evaluated once per matching vertex with the real property map. Only name and properties may 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 with no 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 with rescile.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 name or properties, for example properties={ cidr: '{{ origin_resource.cidr }}' }.

    Impact: Two routers with different CIDRs produce different values, rather than copying one router’s CIDR to both.

  • Use property.index with createFromProperty to 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.index0 here: cidr: '{{ segments[loop.index0] }}'.

    Impact: createFromProperty does not create a Tera {% for %} loop, so loop is unavailable; use property.index instead. Rescile caches identical lib(...) 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.list scope follows the same inference. The path may not depend on value.
  • rescile.externalInput(fetcherName, args, jmespath) declares an external! binding the same way; templated json! paths and external! 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 with audits and outputs in compliance and output files.

Evaluation Timing Note: Do not write iata: locations['{{ value }}'].iata as 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:

  1. 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 "";
}
  1. 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:

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:

  1. Path Confinement: Imports are restricted to the local module root and declared module dependencies (import 'lib/utils.libsonnet').
  2. No Traversal Escape: Attempts to import outside the module root (../../...) fail.
  3. No Absolute Paths: Absolute file imports are rejected.
  4. Deterministic Limits: Evaluator enforces recursion depth caps and execution timeouts.
  5. 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.