Files
regorus/docs/rvm/architecture.md
Mark Birger f0acc64195 feat(compiler): support registered host-await builtins for natural function call syntax (#667)
* 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>
2026-06-29 11:17:25 -05:00

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:

  1. Header: magic REGO bytes followed by SERIALIZATION_VERSION (currently 3).
  2. Section manifest: four little-endian u32 lengths for entry points, sources, literals, and the rule tree, plus a single-byte rego_v0 flag.
  3. Preamble payloads: each section is encoded with postcard using helper wrappers (BinaryValueSlice, BinaryValueRef) to stream complex Value graphs without cloning.
  4. Program core: the remaining Program struct is serialized once more via postcard; 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 own pc.
  • 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 ExecutionFrame objects (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::HostAwait to the caller.
  • Evaluation cache: Used by VirtualDataDocumentLookup to memoise path results.

3. Execution Modes

The VM supports two execution styles selected via set_execution_mode.

Run-to-completion

  • Entry point: RegoVM::execute or execute_entry_point_by_{index,name}.
  • Control loop: execute_run_to_completionjump_to which 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 returns VmError::InstructionLimitExceeded.

Suspendable

  • Entry point: same as above, but the VM calls run_stackless_from which pushes a main ExecutionFrame and dispatches instructions through run_stackless_loop.
  • Frames: Each instruction can adjust the currently active frame or push/pop new frames (rule calls, loops, comprehensions).
  • Suspension: InstructionOutcome::Suspend transitions the VM into ExecutionState::Suspended with a SuspendReason (host await, breakpoint, single-step). The host must call resume with an optional value to continue.
  • Breakpoints & step mode: Configured via set_step_mode and breakpoint mutators on ExecutionState. Execution halts when a frame pc matches 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

  1. Rule entry: The compiler emits a CallRule instruction referencing a rule index. The VM first consults rule_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.
  2. Literal loads: Load and Load* instructions fill registers from the literal table or other sources (LoadData, LoadInput).
  3. Loops: LoopStart fetches LoopStartParams from InstructionData, initialises a LoopContext, and either pushes a new execution frame (for suspendable mode) or updates loop_stack. LoopNext consults loop mode (Any, Every, ForEach) to decide whether to continue or short-circuit.
  4. Comprehensions: ComprehensionBegin/Yield/End manage collection builders stored in a ComprehensionContext. Nested comprehensions stack cleanly with loops.
  5. Assertions: AssertCondition and AssertNotUndefined enforce Rego's truthiness semantics. Inside loops/comprehensions they flag the current iteration as failed (or short-circuit every loops to false); outside loop contexts they raise VmError::AssertionFailed, mirroring Rego's runtime errors for failed guards.
  6. Builtins & functions: BuiltinCall reads BuiltinCallParams, resolves the host function via get_resolved_builtin, and writes the result. Function rules use FunctionCallParams to marshal arguments and run in the same pipeline; repeat invocations with the same arguments are recomputed until the VM grows specialisation-aware caching.
  7. Host await: In run-to-completion mode, HostAwait consumes a response from host_await_responses. Suspendable mode yields control with a SuspendReason::HostAwait { dest, argument, identifier } that the host must service. The compiler supports two ways to emit HostAwait:
    • Explicit: __builtin_host_await(payload, identifier) — raw 2-argument form.
    • Registered: compile_from_policy_with_host_await accepts a list of (name, arg_count) pairs. Calls to registered names are compiled as HostAwait with the function name as the identifier literal. Registered names take precedence over user-defined functions and standard builtins.
  8. Completion: Return wraps the selected register value into InstructionOutcome::Return, unwinding frames until the entry frame is cleared. RuleReturn is 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.