Rhai Mapper and Helper API
Overview
Section titled “Overview”JacqOS exposes Rhai in two distinct execution contexts:
- Mapper context turns raw observations into typed atoms. Mappers are the only place where the
atom(...)host function is registered, because mappers are the structural boundary between external evidence and the semantic logic layer. - Helper context runs pure functions called from
.dhrules via thehelper.<name>(...)prefix. Helpers cannot construct atoms, cannot access observations, and have no awareness of the call site that invoked them.
Both contexts run in the same sandboxed Rhai engine with the same set of bundled language packages. They differ only in which JacqOS host functions are registered. This page documents every host function in each context, the entrypoints the shell expects, the bundled standard library, the resource budgets that apply, and the things that are deliberately excluded.
The portable contract that downstream tools verify is the canonical mapper export (mapper_output_digest), not the Rhai source. Two different Rhai implementations that produce the same canonical export converge on the same digest.
Mapper Context
Section titled “Mapper Context”Entrypoint: map_observation(obs)
Section titled “Entrypoint: map_observation(obs)”Every mapper file must export a top-level function named map_observation that accepts one observation and returns an array of atoms. The shell dispatches each observation to every loaded mapper file and unions the returned atoms into a single batch.
fn map_observation(obs) { let body = parse_json(obs.payload);
if obs.kind == "slot.status" { return [ atom("slot.id", body.slot_id), atom("slot.state", body.state), ]; }
[]}If map_observation is not present in a file under mappings/, the file fails to load.
The obs object
Section titled “The obs object”The observation map passed to map_observation exposes these fields:
| Field | Type | Description |
|---|---|---|
obs.kind | string | Observation classifier (e.g. "booking.request"). |
obs.payload | string | Raw payload, typically a JSON string. Already bounded by payload_limit before Rhai is invoked. |
obs.ref | string | Unique observation reference. Automatically attached to every atom returned. |
obs.timestamp | string | Event-time label supplied by the observation producer or fixture. Native time helpers parse RFC3339 and unix:<millis> values; fixture defaults like fixture-line-1 are ordering labels only. |
obs.source | string | Source identifier (e.g. "webhook", "replay"). |
Optional entrypoint: mapper_contract()
Section titled “Optional entrypoint: mapper_contract()”A mapper may also export a mapper_contract() function that declares which observation classes carry fallible-sensor or fallible-decider evidence and must be relayed through the reserved candidate. or proposal. namespace before the ontology can act on them. The relay-boundary validator reads this contract and rejects any rule that derives an accepted fact or executable intent directly from a relay-marked atom without going through the relay namespace first.
fn mapper_contract() { #{ requires_relay: [ #{ observation_class: "llm.extraction_result", predicate_prefixes: ["extraction.condition", "extraction.medication"], relay_namespace: "candidate", } ], }}Allowed values for relay_namespace are "candidate" and "proposal". The flagship examples (jacqos-incident-response, jacqos-medical-intake, jacqos-chevy-offer-containment, jacqos-drive-thru-ordering) all declare a contract; the simpler examples (jacqos-appointment-booking, jacqos-price-watch, jacqos-smart-farm) omit it because they have no relay-namespaced atoms.
Shared Host Functions
Section titled “Shared Host Functions”These host functions are registered in both mapper and helper context. They are deterministic: they parse caller-supplied values and never read the filesystem, network, environment, host clock, or host timezone.
parse_json(input)
Section titled “parse_json(input)”parse_json(input: String) -> DynamicParses a JSON string into a Rhai value (object map, array, scalar). Throws a runtime error tagged JsonParse when the input is not valid JSON. Walks the parsed tree and rejects any string longer than max_string_size, any array longer than max_array_size, or any object key longer than max_string_size before returning.
let body = parse_json(obs.payload);let email = body.email;let items = body.line_items;Use parse_json when the payload is contractually JSON. Throwing fails the mapper invocation, which is what you want when malformed input should halt processing.
parse_json_opt(input)
Section titled “parse_json_opt(input)”parse_json_opt(input: String) -> DynamicSame as parse_json but returns () (Rhai unit) instead of throwing when the input is not valid JSON. Use this when the payload may be either JSON or some other format and you want the mapper to handle both branches.
let body = parse_json_opt(obs.payload);if body != () { atoms.push(atom("parsed", "true"));}Native time helpers
Section titled “Native time helpers”Time helpers let you derive freshness windows, deadlines, and calendar facts without giving Rhai access to an ambient clock.
Supported input formats:
- RFC3339 timestamps with an explicit zone, such as
2026-04-01T12:00:00Zor2026-04-01T14:00:00+02:00 - Unix millisecond labels in the form
unix:<millis>, such asunix:1775044800000
Fixture defaults such as fixture-line-1 are ordering labels. Time helpers
reject them instead of guessing.
| Function | Returns | Use it for |
|---|---|---|
time_unix_ms(input) | int | Convert RFC3339 or unix:<millis> to Unix milliseconds. |
time_diff_ms(later, earlier) | int | Compute later - earlier in milliseconds. |
time_add_ms(input, delta_ms) | string | Create a deterministic unix:<millis> timestamp. |
time_within_ms(event, anchor, window_ms) | bool | Check that event is not after anchor and is within a non-negative window. |
time_local_date(input, offset_minutes) | string | Get YYYY-MM-DD using a fixed UTC offset. |
time_local_weekday(input, offset_minutes) | int | Get local weekday using a fixed UTC offset, Monday 1 through Sunday 7. |
fn map_observation(obs) { let body = parse_json(obs.payload); let fresh = time_within_ms(body.evidence_at, obs.timestamp, 300000);
[ atom("evidence.id", body.id), atom("evidence.event_ms", time_unix_ms(body.evidence_at)), atom("evidence.fresh_at_request", fresh), ]}For replay-safe freshness, always compare against another observation’s event
time or a payload time you mapped explicitly. Do not ask “is this fresh now?”
inside a mapper or helper; there is no now() function by design.
Mapper-Only Host Functions
Section titled “Mapper-Only Host Functions”The mapper context registers one additional host function.
atom(predicate, value)
Section titled “atom(predicate, value)”atom(predicate: String, value: Dynamic) -> RuntimeAtomConstructs an atom that carries the current observation’s ref, the supplied predicate, and the supplied value. The observation reference is attached implicitly from the invocation context — you never pass it.
atoms.push(atom("booking.email", body.email));atom(...) errors if it is called outside map_observation (for example, from a top-level statement or from a helper file). The error message is mapper invocation is missing observation context.
The value is later canonicalized through a JSON projection. Allowed value types are JSON-representable scalars, arrays, and object maps. Rhai-only values (closures, opaque handles, Fn references) trigger an UnsupportedAtomValue error after map_observation returns.
Helper Context
Section titled “Helper Context”Entrypoint convention
Section titled “Entrypoint convention”Each top-level fn <name>(...) in a file under helpers/*.rhai becomes a callable helper. From .dh rules, invoke it with the helper. prefix:
rule normalized_change(p, n, pct) :- price_observation(p, old, new), pct = helper.abs_pct_change(old, new).Arguments are converted from the engine’s canonical value type before the call and the return value must be JSON-serializable.
Helper host functions
Section titled “Helper host functions”Helper context has the shared JSON and native time helpers documented above. Those functions are pure transformations of caller-supplied values.
Helpers cannot call atom(...). The atom host function is registered only when the engine runs in mapper mode, and even if it were available the implicit observation context would be missing and the call would error. This boundary is intentional: helpers are pure transformations of values; they have no awareness of the observation that triggered the rule that called them.
Real helpers from the examples
Section titled “Real helpers from the examples”These are the helpers shipped with jacqos-price-watch, used end-to-end:
fn abs_pct_change(old_price, new_price) { if old_price == 0.0 { return 0.0; }
let delta = if new_price >= old_price { new_price - old_price } else { old_price - new_price }; let pct = (delta / old_price) * 100.0;
round(pct * 100.0) / 100.0}fn default_price_source(product_id) { if product_id == "prod-100" { "vendor-a" } else if product_id == "prod-200" { "vendor-b" } else if product_id == "prod-300" { "vendor-c" } else { "vendor-a" }}abs_pct_change uses round from the bundled math package; default_price_source uses only language-level conditionals. Neither calls a JacqOS host function — typical for helpers.
Bundled Standard Library
Section titled “Bundled Standard Library”Every Rhai engine the shell builds — mapper, helper, and deterministic LLM provider — registers the same six packages from upstream Rhai. These functions are available alongside the JacqOS host functions in any context.
| Package | Provides |
|---|---|
CorePackage | Type coercion (to_int, to_float, to_string, to_bool), type_of, integer/float arithmetic and comparison. |
LogicPackage | Boolean operators and ordering across primitive types. |
BasicArrayPackage | push, pop, len, contains, map, filter, reduce, indexing, iteration, join. |
BasicMapPackage | Object map literal #{ ... }, contains (key membership), keys, values, indexing. |
BasicMathPackage | abs, round, floor, ceiling, sqrt, pow, min, max, trig, PI, E. |
MoreStringPackage | to_lower, to_upper, trim, trim_start, trim_end, contains, index_of, replace, split, sub_string, len, is_empty, char and byte indexing. |
Idiomatic patterns from the examples include:
// Iterate an array (BasicArrayPackage)for dependency in body.depends_on { atoms.push(atom("service.depends_on", dependency));}
// Membership-test an object map key (BasicMapPackage)if body.contains("production_system") { if body.production_system == true { atoms.push(atom("service.production_system", service_id)); }}
// Type coercion (CorePackage)let succeeded = to_string(body.succeeded);Method-style and function-style calls are interchangeable in Rhai (s.replace(".", "") and replace(s, ".", "") resolve identically). The string and array packages expose both.
What’s NOT available
Section titled “What’s NOT available”The Rhai sandbox is intentionally narrow. None of the following are exposed in mapper or helper context:
- No I/O. No filesystem access, no network, no shell escape, no environment variable reads.
- No LLM access. Mappers and helpers cannot invoke models. LLM calls happen through the
llm.completeeffect capability and surface back as observations. - No ambient clock or host timezone. No
now(), noDate.now(), no system clock, and no host timezone database. Native time helpers parse explicit timestamps only. Useobs.timestampor payload fields as event-time evidence. - No randomness. No
rand(), no UUID generation. Determinism is required for replay. - No module imports.
set_max_modules(0)is set on every engine;importandexportare unavailable. - No dynamic code. The following Rhai symbols are explicitly disabled and emit a parse error if used:
debug,eval,Fn,call,curry,import,export,is_def_fn,is_def_var,print. - No fact reads. Mappers cannot query derived facts. Mappers are the only stage allowed to construct atoms; the ontology decides what facts derive from those atoms.
- No effect emission. Mappers cannot derive intents. Intents are the exclusive output of
.dhderivation rules. - No
atom(...)from helpers. As documented above,atomis mapper-only. - No mutation of
obs. The observation passed tomap_observationis read-only evidence.
For genuinely hard work that exceeds Rhai’s reach — complex binary parsing, performance-critical computation — the escape hatch is a pre-compiled Wasm helper, not an extension to the Rhai surface.
Resource Budgets
Section titled “Resource Budgets”Mappers process raw external data and must be protected against malicious or oversized payloads. Every mapper invocation runs under the limits below.
Defaults
Section titled “Defaults”| Limit | Default | Override |
|---|---|---|
max_operations | 100,000 | [mapper_budgets.<name>] max_operations = N |
max_string_size | 1 MB | [mapper_budgets.<name>] max_string_size = "2MB" (suffix string or integer bytes) |
max_array_size | 10,000 | [mapper_budgets.<name>] max_array_size = N |
payload_limit | 10 MB | [mapper_budgets.<name>] payload_limit = "20MB" |
timeout | 5 s | [mapper_budgets.<name>] timeout = "10s" (duration string or integer seconds) |
The <name> is the mapper file’s stem — a budget under [mapper_budgets.inbound] applies to mappings/inbound.rhai.
De facto V1 baseline
Section titled “De facto V1 baseline”Every shipped example overrides the defaults to the same uniform baseline:
[mapper_budgets.inbound]max_operations = 150000max_string_size = "2MB"max_array_size = 12000payload_limit = "12MB"timeout = "10s"Treat these numbers as the V1 baseline for production mappers. Lower them only after measurement.
How limits fire
Section titled “How limits fire”| Limit | Mechanism | Error surfaced |
|---|---|---|
payload_limit | Checked before Rhai is invoked. | Mapper invocation is skipped with a payload-too-large error. |
max_operations | Charged per Rhai bytecode step by the engine’s own counter. | OperationLimitExceeded. |
max_string_size / max_array_size | Enforced both during execution and during JSON tree walks performed by parse_json / parse_json_opt and during atom value canonicalization. | Runtime error from the offending operation. |
timeout | Enforced via Rhai’s on_progress callback, which injects a sentinel string when wall-clock elapsed exceeds timeout. Fires only at bytecode-step boundaries, not inside a single host call. | Timeout. |
Helper budgets
Section titled “Helper budgets”Helper budgets are not configurable in V1. Every Rhai helper compiles and runs under MapperResourceLimits::default() (the defaults from the table above). There is no [helper_budgets] section in jacqos.toml. Wasm helpers run under a separate hard-coded fuel budget.
Multiple mapper files
Section titled “Multiple mapper files”Mappers can be split across files. Each file handles different observation kinds:
mappings/ inbound.rhai # Webhook and API observations providers.rhai # Provider-specific parsing[paths]mappings = ["mappings/*.rhai"]All mapper files load together. The shell dispatches each observation to every mapper; atoms from every mapper merge into one batch.
Canonical Mapper Export
Section titled “Canonical Mapper Export”Two different mapper implementations that produce the same canonical export converge on the same mapper_output_digest. This means you can:
- Refactor mapper code without changing semantics.
- Have different teams maintain different mapper implementations against the same digest.
- Verify that a refactor did not alter observable atom output.
$ jacqos verifyChecking mapper exports... inbound.rhai digest: sha256:a1b2c3... providers.rhai digest: sha256:d4e5f6...Mapper output stable.Hot Reload Behavior
Section titled “Hot Reload Behavior”| Change | What happens | Speed |
|---|---|---|
| Mapper code change | Regenerate atoms from observation log, rerun derivation. | Proportional to observation count. |
| Helper code change | New evaluator digest, rebuild derived state. | Sub-250ms for small corpora. |
| New observation | Mapper runs on new observation only, incremental evaluation. | Milliseconds. |
The shell reports which reload path it chose so you can tell whether you hit the fast path or triggered a full rebuild.
End-to-End Mapper Template
Section titled “End-to-End Mapper Template”The following is the complete mapper from examples/jacqos-incident-response/mappings/inbound.rhai. It exercises every public mapper-context feature: the relay contract, JSON parsing, conditional key membership, array iteration, and atom(...) for both unconditional and conditional atoms.
fn mapper_contract() { #{ requires_relay: [ #{ observation_class: "llm.remediation_decision_result", predicate_prefixes: ["proposal."], relay_namespace: "proposal", } ], }}
fn map_observation(obs) { let body = parse_json(obs.payload);
if obs.kind == "topology.update" { let atoms = []; let service_id = body.service_id; atoms.push(atom("service.id", service_id));
if body.contains("depends_on") { for dependency in body.depends_on { atoms.push(atom("service.depends_on", dependency)); } }
if body.contains("production_system") { if body.production_system == true { atoms.push(atom("service.production_system", service_id)); } }
if body.contains("admin_access") { if body.admin_access == true { atoms.push(atom("service.admin_access", service_id)); } }
if body.contains("is_primary_db") { if body.is_primary_db == true { atoms.push(atom("service.primary_db", service_id)); } }
if body.contains("replica_synced") { if body.replica_synced == true { atoms.push(atom("service.replica_synced", service_id)); } }
return atoms; }
if obs.kind == "telemetry.alert" { return [ atom("health.service", body.service_id), atom("health.status", body.status), atom("health.seq", body.seq), ]; }
if obs.kind == "llm.remediation_decision_result" { return [ atom("proposal.id", body.decision_id), atom("proposal.root_service", body.root_service), atom("proposal.target_service", body.target_service), atom("proposal.action", body.action), atom("proposal.seq", body.seq), ]; }
if obs.kind == "effect.receipt" { let atoms = []; atoms.push(atom("effect.kind", body.kind)); atoms.push(atom("effect.root_service", body.root_service)); atoms.push(atom("effect.result", body.result));
if body.contains("target_service") { atoms.push(atom("effect.target_service", body.target_service)); }
return atoms; }
[]}The proposal.* atoms emitted from llm.remediation_decision_result flow into the relay namespace declared by mapper_contract(). The ontology then has to ratify them via an explicit decision relation before any intent.* rule can fire — the relay boundary the contract declares is what enforces that.