Files
regorus/docs/rvm/architecture.md
Anand Krishnamoorthi 006e819d52 rvm: switch binary serialization to postcard (#582)
Move RVM binary encoding from bincode to postcard and bump the format version. Update test helpers, docs, changelog, and refresh lockfiles after the swap.

Closes #575

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
2026-03-03 15:09:45 -06: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.
  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.