* feat(compiler): support registered host-await builtins Allow hosts to register function names at compile time so that calls to those names emit HostAwait instructions directly, enabling natural syntax like fetch(x) instead of __builtin_host_await(x, "fetch"). - Add host_await_builtins map and register_host_await_builtin() to Compiler - Validate arg_count == 1 and reject reserved __builtin_host_await name - Extend determine_call_target() resolution: explicit > registered > user > builtin - Both explicit and registered paths emit identical HostAwait bytecode - Add compile_from_policy_with_host_await() entry point in rules.rs - Extended test harness with HostAwaitBuiltinSpec and args assertion - 9 YAML test cases: suspend/resume, run-to-completion, multiple names, queue, shadowing, object packing, arg_count rejection, reserved name rejection, standard builtin override - Documentation: instruction-set.md, architecture.md * Update src/languages/rego/compiler/function_calls.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Mark Birger <birgerm@yandex.ru> * fix(compiler): address PR #667 review feedback on host-await registration - Compiler::register_host_await_builtin now rejects duplicate, empty, and whitespace-only names. Previously a duplicate registration would silently overwrite the existing entry, which could mask the host's own registration mistakes. - YAML test cases added: empty registration list as no-op, duplicate name rejection, empty/whitespace name rejection, out-param (a, out) calling syntax with a single-arg registered builtin, and mixed __builtin_host_await + registered builtins in the same policy consuming from their respective identifier queues. - Test harness: replace assert_eq! on HostAwait argument mismatch with anyhow::Error so mismatches propagate through the case reporter instead of panicking and skipping the harness's normal error path. - YAML comment fix: "Registration panics" -> "Registration fails with an error" (registration returns Err, never panics). Addresses anakrish + Copilot inline review comments on PR #667. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * compiler: split CallTarget::HostAwait into explicit and registered variants Addresses PR #667 review item #8: at the emit site in `compile_function_call`, the discrimination between explicit `__builtin_host_await(arg, id)` and a registered host-awaitable builtin was being recovered by string-comparing `original_fcn_path` against `"__builtin_host_await"`. The information was already known in `determine_call_target` and was being thrown away. Replace the single `CallTarget::HostAwait` variant with two: * `ExplicitHostAwait` (unit) — the two-argument call form. The identifier register comes from the user's second argument. * `RegisteredHostAwait { identifier: String }` — the one-argument call form for registered builtins. The identifier is the registered name and is captured in the variant at recognition time, so the emit site never re-derives it from the function path. This removes the magic-string comparison at the emit site (the source of truth is now `determine_call_target`) and makes both match sites in `compile_function_call` exhaustive over the two forms — adding a third host-await form in the future would force a compile error at every match site instead of silently falling through. Arities are now hardcoded in the `expected_args` extraction (`Some(2)` for explicit, `Some(1)` for registered) rather than carried in the variant; registered builtins are constrained to `arg_count == 1` at registration time, so there is no per-call variability to carry. Bytecode output is unchanged; the full RVM test suite (97 cases) and the registered_host_await suite (15 cases) pass without modification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(compiler): clarify registered host-await intercepts unqualified calls only PR #667 review (Medium): the docs implied registered host-await names shadow user functions and builtins unconditionally, but determine_call_target matches only the bare original_fcn_path. A package-qualified call such as data.demo.resolve(x) is therefore not intercepted -- it resolves through the normal path like any other call. Rather than expand registration to qualified paths (which would let a registered name leak into every package exposing a same-named rule), document the unqualified-only behavior and pin it with tests. - register_host_await_builtin: doc now states only the unqualified call form is intercepted; qualified calls resolve normally. - determine_call_target: inline comment explaining the deliberate original_fcn_path-only match. - docs/rvm/instruction-set.md: describe qualified-call resolution, including that builtins have no qualified form. - tests: cross-package and same-package qualified calls resolve to the rule; bare-name shadowing of a standard builtin; Unknown-function outcome when no rule exists at the qualified path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): compare host-await argument without re-running process_value PR #667 review (Low): the suspendable test harness compared the host-await argument via process_value(argument), but argument is already a runtime Value. process_value is a YAML-fixture decoder -- it rewrites "#undefined" to Undefined, {set!: [...]} to a set, and errors on a runtime Value::Set. Re-running it on the runtime argument could coerce a legitimate payload into a fixture sentinel (passing for the wrong reason) or error outright on sets. Compare the runtime argument directly against the expected value, which is already decoded once at YAML load time. Add a regression case (registered_builtin_suspendable_set_argument) that passes a set payload: it fails under the old double-processing ("unexpected set in value read from json/yaml") and passes with the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): reject `args:` payload expectations in run-to-completion mode PR #667 review (Low): a run-to-completion host-await response could carry an `args:` payload expectation, but RTC execution pre-loads responses and never surfaces the call argument to the harness, so the expectation was parsed and silently dropped. A case with `args: "WRONG"` passed as long as the result matched -- asserting a payload that was never checked. Reject `args:` for run-to-completion fixtures at load time, directing the author to suspendable mode where arguments are validated. Also only build the run-to-completion response vector when the case actually runs in RTC mode, so a suspendable case using the shared host_await_responses field with `args:` is not wrongly rejected. Route the fixture-load error through the same want_error handling used for compilation errors, and add registered_builtin_run_to_completion_rejects_args which now fails loudly instead of passing silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(compiler): reject host-await builtin names with surrounding whitespace PR #667 review (Low): register_host_await_builtin rejected all-whitespace names via name.trim().is_empty(), but accepted padded names like " lookup" or "lookup ". Those were inserted into host_await_builtins, but Rego function-call paths produce the trimmed identifier, so a padded registration could never match -- a silent dead registration. Reject any name that is not already trimmed (name != name.trim()) in addition to empty names, and update the error message accordingly. Add test cases for leading and trailing whitespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Signed-off-by: Mark Birger <birgerm@yandex.ru> Co-authored-by: Mark Birger <markbirger@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
17 KiB
Regorus Virtual Machine Architecture
This document explains how Rego source becomes executable bytecode and how the runtime evaluates it. It is meant for three audiences:
- Engine developers working on the RVM execution core and runtime subsystems.
- Policy front-end authors targeting the VM from alternate policy languages.
- Operators/tools wanting to reason about execution behaviour and troubleshooting output.
The high-level pipeline looks like this:
┌───────────┐ emit Program ┌────────────┐ load & run ┌─────────┐
│ Parser & │ ───────────────▶ │ Program │ ─────────────▶ │ Rego VM │
│ Compiler │ (bytecode) │ Artifact │ (instructions│ Runtime │
└───────────┘ │ │ + metadata) │ │
└────────────┘ └─────────┘
Each step feeds the next via well-defined data structures described below.
RVM in context
The Rego VM uses a register-based architecture with the following traits:
- Register windows per frame: Each rule or function call receives a compile-time-sized register window. Windows are pooled and reused to keep the runtime allocation profile predictable.
- Sequential bytecode stream: Fixed-width 32-bit instructions execute from
a linear program counter with optional jumps. Complex instructions reference
shared tables (
InstructionData) that carry literals, loop metadata and call parameters. - Literal and builtin tables: Literal pools and builtin dispatch tables are resolved at load time so bytecode stays compact and symbol lookups remain constant-time during execution.
- Extended control stacks: Loop, rule-cache and comprehension stacks sit alongside the core call stack, enabling suspension, short-circuiting and deterministic rule caching without growing the register windows themselves.
1. Compilation Outputs
A successful compilation produces a Program (src/rvm/program/core.rs). The
layout is deliberately split:
- Stable artifact section: Always serialised and treated as canonical. It captures the original policy sources, entry-points, compiler options, etc.
- Synthesised execution section: It contains the compiled instruction stream, instruction parameter tables, literal tables, etc. It can be recreated from the stable artifact section if a future RVM version is note able to deserialize it.
| Field | Purpose | Notes |
|---|---|---|
instructions: Vec<Instruction> |
Ordered bytecode emitted by the compiler. | Each opcode is defined in src/rvm/instructions/mod.rs and executed by the dispatch tree. |
literals: Vec<Value> |
Literal constants shared across instructions. | Skipped by serde but written in the binary format via BinaryValueSlice; avoids duplicating large value graphs. |
instruction_data: InstructionData |
Parameter tables for complex opcodes. | Tables are indexed by params_index values stored in instructions. |
builtin_info_table: Vec<BuiltinInfo> |
Metadata for builtin calls. | Enforced and resolved by Program::initialize_resolved_builtins. |
entry_points: IndexMap<String, usize> |
Maps path names (e.g. data.pkg.rule) to starting PCs. |
Preserves declaration order for tooling and serialized in the artifact section. |
sources: Vec<SourceFile> |
Captures original policy sources. | Stored in the stable artifact section alongside entry-points. |
rule_infos: Vec<RuleInfo> |
Metadata for every rule. | Includes register windows, default values, destructuring blocks. |
instruction_spans: Vec<Option<SpanInfo>> |
Optional span info for diagnostics. | Lines/columns mapped back into the source table when present. |
main_entry_point: usize |
Default bytecode entry point. | Used by loaders to jump into the top-level policy. |
max_rule_window_size / dispatch_window_size |
Register window sizing hints. | The VM uses these to size register banks up-front. |
metadata: ProgramMetadata |
Compilation metadata (compiler_version, etc.). |
Helps operators verify provenance and tooling compatibility. |
rule_tree: Value |
Map of rule labels for conflict detection and lookups. | Serialized via BinaryValueRef; rebuilt into a Value::Object during load. |
resolved_builtins: Vec<BuiltinFcn> |
Resolved builtin function pointers. | Not serialized; repopulated by the host at load time. |
needs_runtime_recursion_check: bool |
Flags when VirtualDataDocumentLookup requires runtime guards. |
Ensures the VM short-circuits recursion before hitting the instruction budget ceiling. |
needs_recompilation: bool |
Indicates partial deserialization of execution data. | Set when the extensible section fails; signals the loader to recompile. |
rego_v0: bool |
Records whether the policy targeted Rego v0 semantics. | Ensures recompilation preserves language-version behaviour. |
Additional helpers such as Program::add_*, Program::update_*, and
Program::display_instruction_with_params are used by the compiler and
inspection tooling to populate and render the program.
Serialization layout
The module src/rvm/program/serialization writes Program instances into a
compact binary envelope that stays forward-compatible within a major format
version:
- Header: magic
REGObytes followed bySERIALIZATION_VERSION(currently3). - Section manifest: four little-endian
u32lengths for entry points, sources, literals, and the rule tree, plus a single-byterego_v0flag. - Preamble payloads: each section is encoded with
postcardusing helper wrappers (BinaryValueSlice,BinaryValueRef) to stream complexValuegraphs without cloning. - Program core: the remaining
Programstruct is serialized once more viapostcard; fields skipped by serde (entry points, literals, sources, rule_tree, resolved builtins) are re-inserted from the preamble when the program is reconstructed.
During deserialization the loader sanity-checks the header, lengths, and
version before decoding each preamble section. Any failure while decoding the
core payload downgrades the result to DeserializationResult::Partial,
preserving enough artifact data to trigger a recompilation. Successful loads
call Program::initialize_resolved_builtins so host runtimes can plug in their
builtin implementations.
2. Runtime Subsystems
At evaluation time the RegoVM (src/rvm/vm/machine.rs) consumes a Program
and exposes execution APIs. The VM separates concerns through specialised
stacks and caches.
Runtime stacks (run-to-completion)
┌──────────────────────────── RegoVM ─────────────────────────────┐
│ Registers (active window) ─────┐ │
│ Program counter (pc) ───────┐ │ │
│ ▼ ▼ │
│ Control flow dispatcher ───────────────▶ Instruction stream │
│ ▲ ▲ │
│ Rule cache ────────┐ │ │ Loop stack (LoopContext) │
│ Evaluation cache │ │ └──▶ Comprehension stack │
│ Host await queue ──┴─▶ Return values / suspensions │
└─────────────────────────────────────────────────────────────────┘
Suspendable mode frame stack
┌───────────────────────────────────────────────────────────────────────┐
│ Frame stack │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌──────────────────────┐ │
│ │ RuleFrame │ → │ LoopFrame │ → │ ComprehensionFrame │ │
│ └────────────────┘ └────────────────┘ └──────────────────────┘ │
│ ▲ ▲ ▲ │
│ │ push frame │ push frame │ push frame│
│ ▼ ▼ ▼ │
│ allow { ... } │
│ some user in input.users │
│ [x | ... ] │
└─────────┴─────────────────────────────────────────────────────────────┘
Execution state machine (suspendable)
┌──────────────────────┐
│ Suspended │
└─────▲────────────┬───┘
│ │
| │
│ │
│ │
HostAwait/Breakpoint/Step │ | resume
│ │
│ │
| ▼
┌──────────┐ ┌───────────────────────────┐ Return ┌────────────┐
│ Ready ├─────────────▶│ Running │────────────▶│ Completed │
└──────────┘ └────────────┬──────────────┘ └────────────┘
│ VmError
▼
┌──────────┐
│ Error │
└──────────┘
Key state:
- Registers: The active register window for the current frame. Windows are allocated per rule call using a register pool to minimise allocations.
- Program counter (
pc): The bytecode index for run-to-completion mode. In suspendable mode, each frame tracks its ownpc. - Rule cache: Stores results and completion flags per rule to avoid recomputation.
- Loop/comprehension stacks: Track iteration state, completion criteria, and pending yields.
- Execution stack: Present in suspendable mode. Stores
ExecutionFrameobjects (FrameKind::Rule,Loop,Comprehension) so that the VM can pause and resume evaluation cleanly. - Host await responses: For run-to-completion execution, pre-defined values
keyed by identifier. Suspendable mode instead returns a
SuspendReason::HostAwaitto the caller. - Evaluation cache: Used by
VirtualDataDocumentLookupto memoise path results.
3. Execution Modes
The VM supports two execution styles selected via set_execution_mode.
Run-to-completion
- Entry point:
RegoVM::executeorexecute_entry_point_by_{index,name}. - Control loop:
execute_run_to_completion→jump_towhich iterates the instruction stream sequentially. - Suspension: Unsupported. Any instruction that would suspend emits a runtime error because the host cannot resume.
- Traps: Instruction budget enforced via
max_instructions; exceeding the limit returnsVmError::InstructionLimitExceeded.
Suspendable
- Entry point: same as above, but the VM calls
run_stackless_fromwhich pushes a mainExecutionFrameand dispatches instructions throughrun_stackless_loop. - Frames: Each instruction can adjust the currently active frame or push/pop new frames (rule calls, loops, comprehensions).
- Suspension:
InstructionOutcome::Suspendtransitions the VM intoExecutionState::Suspendedwith aSuspendReason(host await, breakpoint, single-step). The host must callresumewith an optional value to continue. - Breakpoints & step mode: Configured via
set_step_modeand breakpoint mutators onExecutionState. Execution halts when a framepcmatches a registered breakpoint.
In both modes the VM constantly validates safety conditions: parameter indices
must resolve, register windows must exist, and results must stay inside the
supported Value lattice. Errors are reported as VmError variants that
include formatted state snapshots where possible.
4. Data-flow Walkthrough
- Rule entry: The compiler emits a
CallRuleinstruction referencing a rule index. The VM first consultsrule_cache[rule_index]; non-function rules that have already executed within the current top-level run reuse the cached result. When the cache is cold, the VM pushes a new rule frame, allocates a register window and jumps to the rule entry point. Function rules always run afresh today—per-specialisation memoization is not yet implemented. - Literal loads:
LoadandLoad*instructions fill registers from the literal table or other sources (LoadData,LoadInput). - Loops:
LoopStartfetchesLoopStartParamsfromInstructionData, initialises aLoopContext, and either pushes a new execution frame (for suspendable mode) or updatesloop_stack.LoopNextconsults loop mode (Any,Every,ForEach) to decide whether to continue or short-circuit. - Comprehensions:
ComprehensionBegin/Yield/Endmanage collection builders stored in aComprehensionContext. Nested comprehensions stack cleanly with loops. - Assertions:
AssertConditionandAssertNotUndefinedenforce Rego's truthiness semantics. Inside loops/comprehensions they flag the current iteration as failed (or short-circuiteveryloops tofalse); outside loop contexts they raiseVmError::AssertionFailed, mirroring Rego's runtime errors for failed guards. - Builtins & functions:
BuiltinCallreadsBuiltinCallParams, resolves the host function viaget_resolved_builtin, and writes the result. Function rules useFunctionCallParamsto marshal arguments and run in the same pipeline; repeat invocations with the same arguments are recomputed until the VM grows specialisation-aware caching. - Host await: In run-to-completion mode,
HostAwaitconsumes a response fromhost_await_responses. Suspendable mode yields control with aSuspendReason::HostAwait { dest, argument, identifier }that the host must service. The compiler supports two ways to emitHostAwait:- Explicit:
__builtin_host_await(payload, identifier)— raw 2-argument form. - Registered:
compile_from_policy_with_host_awaitaccepts a list of(name, arg_count)pairs. Calls to registered names are compiled asHostAwaitwith the function name as the identifier literal. Registered names take precedence over user-defined functions and standard builtins.
- Explicit:
- Completion:
Returnwraps the selected register value intoInstructionOutcome::Return, unwinding frames until the entry frame is cleared.RuleReturnis a specialised variant used by rule execution helpers.
Throughout execution, diagnostics (register snapshots, loop counters, cache
hits) can be collected via RegoVM accessors. Integration tests in
tests/rvm/vm/suites exercise the most complex combinations of loops,
comprehensions and host calls; complex.yaml is a good starting point for
understanding real-world instruction streams.