Skip to content

THE FIELD GUIDE

Native FFI specification

Normative native extension ABI, ownership, validation, and lifecycle rules.

View source · lang/docs/spec/native-ffi.md
On this page

Version: 1.0 Status: Draft

1. Scope#

This specification defines the native extension contract for Vo modules. It covers:

  • the canonical vo.mod extension metadata schema
  • explicit published target declarations
  • mapping from manifest fields to vo.release.json artifact identities
  • the runtime entry-table contract for Rust-backed extensions
  • the separation between local build adapters and published runtime identity

This specification does not define:

  • source-language syntax for function declarations without bodies
  • non-Rust extension backends
  • host-application metadata beyond the module-owned [web], [extension.web], and [extension.web.js] tables

2. Design Principles#

  • Manifest-first publication. Published extension support is declared in vo.mod, not inferred from CI logs, repository layout, or artifact filenames alone.
  • Explicit target support. A target is supported only if it is explicitly declared in vo.mod.
  • Partial target support is normal. A module version may support some targets and omit others.
  • No implicit fallback for published dependencies. If a published dependency needs a native artifact for the active target, the build uses the artifact authenticated by the locked release or fails.
  • Separation of build adapters and published identity. [build.*] selects local inputs. [extension.*] declares public targets and logical names; native platform filenames are derived from the public library stem.
  • Tool-specific metadata isolation. Module-system parsers reject unknown root metadata tables. Browser/app metadata that the module system parses must live in [web], [extension.web], or [extension.web.js].
  • Allocator ownership stays local. A host and a shared library may use different Rust allocators. Owned Rust values never cross the native ABI.

3. Runtime Contract#

3.1 Rust-backed Extensions#

Vo currently supports Rust-backed native extensions compiled as shared libraries and loaded through three exported ABI entry points. Users should depend on the vo_ext crate. The Rust crate must point at the owning module's authoritative vo.mod; the macro derives both the canonical package key and the exact module owner from that file.

# rust/Cargo.toml
[dependencies]
vo-ext = "=0.1.4"
vo-runtime = "=0.1.4"

[package.metadata.vo]
vomod = "../vo.mod"
use vo_ext::prelude::*;

#[vo_fn("myext/math", "Add")]
fn add(a: i64, b: i64) -> i64 {
    a + b
}

vo_ext::export_extensions!();

The publishable vo-stdlib-source crate is rooted at lang/stdlib and owns the canonical stdlib.toml and .vo assets. vo-ffi-macro, compiler, and runtime consumers MUST use that materialized package or its embedded bytes as their standard-library source. A distribution MUST NOT introduce a second, independently maintained copy of those assets.

3.2 Entry Table#

Rust-backed extensions expose vo_ext_get_abi_version, vo_ext_get_abi_fingerprint, and vo_ext_get_entries, generated by vo_ext::export_extensions!(). The runtime calls and validates the version and fingerprint exports before calling the entry-table export. It rejects an entry count above 65,536, a name above 4,096 bytes, null or misaligned table pointers, invalid UTF-8 names, duplicate names, and invalid effect bits before registration. Every name must use the canonical extern codec vo1:<package-byte-length>:<package>:<function-byte-length>:<function> and decode completely. Codec decoding is structural and lossless: it preserves the exact UTF-8 field bytes, including a leading U+FEFF. The bytecode verifier and every provider/native/WASM catalog then apply the shared semantic gate. The decoded package must satisfy the canonical package-identity rules in module.md; the decoded function must be one complete Unicode 16.0 Vo named declaration identifier, excluding _ and every language keyword. Every entry carries the exact canonical ModulePath read from its authoritative vo.mod. The loader requires that owner to equal the module owner selected by the manifest and requires every decoded package to equal that owner or begin with the exact owner followed by /. A legacy flattened name, malformed codec input, prefix collision, or package owned by a different module rejects the complete table. The human-facing [extension].name never participates in language symbol identity. One canonical module owner maps to exactly one native artifact in a loader; an exact repeated spec is idempotent, while a different library attempting to split the same owner namespace is rejected.

Before module extern resolution, the runtime builds one deduplicated owner catalog from the complete process-local linkme table and every loaded dynamic extension. It selects the longest canonical owner boundary for each decoded package before looking up the exact extern name. A deeper loaded owner that omits the requested function produces a missing-provider error; dispatch never falls back to an entry exported by a parent owner. Parent-first and child-first loading therefore produce the same active catalog. One exact owner may come from one linkme provider or one dynamic artifact, and a split across both sources is rejected transactionally. Shadowed libraries remain alive while their function pointers are excluded from the active catalog.

One VM owner catalog also forbids an exact owner from being split across native and browser-WASM transports. The owner claim is carried by the complete artifact catalog, so an artifact with an empty extern table still reserves its exact owner boundary. Parent and nested-child owners remain independent.

Process-local linkme providers declare module ownership in the independent EXTERN_MODULE_OWNER_TABLE. Each statically linked extension artifact emits exactly one module-level owner declaration even when EXTERN_TABLE has no function entries. Invalid UTF-8, non-canonical owners, duplicate declarations, or a function entry referencing an undeclared owner rejects the complete linkme catalog before registration.

Typed static-WASM registration uses an explicit export_extensions!(...) table. Each element SHOULD use vo_ext::vo_extension_entry!("package", "Function"); a nested implementation uses vo_ext::vo_extension_entry!(native, "package", "Function"). The expression macro accepts string literals only, resolves the same authoritative vo.mod and bodyless source declaration as #[vo_fn], and expands to the exact injectively mangled generated entry constant. The export macro places each expression exactly once in the target-specific static table; callers never depend on an internal generated Rust identifier.

#[repr(C)]
pub struct ExtensionTable {
    pub version: u32,
    pub entry_count: u32,
    pub entries: *const ExternEntry,
}

#[repr(C)]
pub struct ExternEntry {
    pub name_ptr: *const u8,
    pub name_len: u32,
    pub module_owner_ptr: *const u8,
    pub module_owner_len: u32,
    pub func: Option<ExternFnPtr>,
    pub effects_bits: u64,
}

Both byte ranges are UTF-8 and remain alive for the table's lifetime. The nullable function-pointer representation makes a zero address valid to read at the raw table boundary. The loader rejects None before an entry is registered or invoked.

effects_bits encodes ExternEffects. Invalid bit patterns are rejected at extension load time. UNKNOWN_CONTROL is only valid by itself; precise effects must use the corresponding MAY_* bits.

Canonical extern identity#

Every source-declared extern is identified by the tuple (package, function). package is the complete canonical Vo import path and function is the exact source identifier. The extension manifest name describes an artifact/provider and does not participate in language symbol identity.

The entry-table wire name uses this versioned, length-delimited form:

vo1:<package-byte-length>:<package>:<function-byte-length>:<function>

Lengths count UTF-8 bytes and use non-zero canonical decimal notation without leading zeroes. Decoders consume the complete input and reject malformed UTF-8 boundaries, trailing bytes, empty fields, and encoded names above 4,096 bytes. Implementations preserve package path separators and identifier spelling exactly; they do not clean paths, replace punctuation, fold case, or infer an extension name. Native providers may publish a module root package and any package whose canonical path begins with that root followed by /.

Bytecode also uses a closed set of compact names for compiler-synthesized, VM-owned helpers such as dynamic dispatch and primitive conversions. The toolchain publishes that exact whitelist from vo-common-core; the bytecode verifier rejects every other non-canonical name. Native, linkme, stdlib, WASM, manual, and test providers cannot register a compact helper name. Runtime builtins may register only a canonical name or an exact member of the VM whitelist, so provider APIs cannot revive legacy flattened identities.

The ABI fingerprint includes the physical GC header, canonical array, compact three-slot string descriptor, seven-slot slice prefix, nine-slot extended slice layout and its tags, and the Map header, iterator, managed backing geometry, bucket control encoding, and key hash scheme. An extension built against an earlier object representation or key hash scheme MUST be rebuilt even when its C callback table still uses ABI v10. The loader MUST reject a fingerprint mismatch before invoking any extension entry.

3.3 ABI-v10 Call Boundary#

Every native entry has the C signature extern "C" fn(*mut ExtAbiContextV10) -> u32. ExtAbiContextV10 contains a version/size header, an opaque host pointer, a versioned host-operation table, the primitive slot window, and call metadata. The extension must treat the host pointer as opaque.

The generated trampoline validates the context header before reading later fields, then validates the host-operation header. It snapshots both structures for the duration of the call. A null pointer, version or size mismatch, misalignment, invalid slot window, or secondary panic produces RESULT_ABI_ERROR. No Rust panic may unwind through an extern "C" boundary. Raw callback fields use nullable C function-pointer representations. The constructor rejects a missing required operation or host-service callback before creating the extension facade.

ABI v10 retains result code RESULT_WAIT_IO = 3 and the ExtHostOpsV10::set_wait_io callback slot solely to preserve the published C layout and fingerprint. They are reserved compatibility slots, not a native extension capability. Calling set_wait_io records a contract violation, and returning RESULT_WAIT_IO is rejected with the same structured error. A native extension that performs asynchronous I/O submits work through HostServices V2 and returns HostEventWaitAndReplay; the replay invocation consumes the host-event token and optional data. Same-image runtime and stdlib providers may continue to use the VM-owned IoRuntime/WaitIo protocol.

Argument and return helpers address slots relative to their declared windows. One-slot scalar/reference operations and two-slot interface operations must fit completely. Offset arithmetic is checked in the u16 slot domain, including the final address of each non-empty frame window. Invalid reads produce inert values, invalid writes do not mutate the stack, and both record a contract failure. The host rejects the call and restores its pre-call return snapshot.

Primitive scalar slots use the frame's u64 stack window. All operations tied to allocator or collector ownership use host callbacks, including:

  • borrowing string and byte arguments
  • writing output and host-event payloads
  • copying panic and closure-call payloads into host storage
  • allocating and marking supported GC objects in the host collector
  • recording contract failures

Pointer/length inputs are borrowed only for the callback or provider call that documents them. The receiver copies data whenever it must retain ownership. String, Vec, Box, Rust trait objects, references to Rust collections, and other Rust-owned values must never cross the shared-library boundary.

The extension-facing GC value is a dispatch facade. Supported allocation and barrier operations execute in the host image. Collector control/telemetry and objects with allocator-specific payloads fail closed. Map accessors are kept private to same-image runtime tests and are absent from vo_ext::prelude. GC allocation callbacks carry an explicit allocation intent: ordinary object, canonical array, or bare value-slot box. The host validates that intent against the value kind, canonical module metadata, header width, and total physical width before allocating.

ABI 10 also adds gc_lease_create, gc_lease_resolve, and gc_lease_release. A GcLease { index, generation } is a host-owned precise root for an object retained across calls or safe points. A dynamic extension must resolve the lease immediately before use and must release it when finished. Stale generations, foreign indices, null objects, and exhausted lease capacity fail closed. Retaining an unleased raw GcRef across a safe point violates the ABI.

Typed container access splits readable VoElem from mutable VoWritableElem. VoStringElem is read-only, while VM-owned string handles can be written through GcRef. Boolean elements occupy one logical slot and one physical byte in canonical packed storage. Flat-slot aliases retain their declared storage stride. Slice and array cursors implement Iterator and ExactSizeIterator.

3.4 VM-scoped Host Services#

ExtHostOpsV10 embeds an independently versioned ExtHostServicesV2 C callback table. It contains capability, timeout, interval, and tick-loop operations. The callbacks accept the opaque call-frame host pointer, borrowed byte ranges, and scalar values only. Rust trait objects, their vtables, provider collections, and allocator-owned values stay in the host executable.

The host stores one Arc<dyn HostServices> in each VM. A child island clones that owner before loading extensions or running package initialization. Two VMs may therefore use different providers concurrently without sharing mutable bridge state. A service generation may be installed after module load and before the first fiber executes. Replacing or clearing it is rejected after execution starts or while the parent owns a live child-island thread, preserving one immutable generation for the complete VM execution and island tree. Dropping the VM first stops and joins its child threads, then releases the final service owners.

The generated ABI-v10 trampoline installs the callback table in a thread-local scope for exactly one provider invocation. Scopes are nestable and an RAII guard restores the previous scope during normal return and panic unwinding. Provider panics are caught inside the host callback and turn the native call into a contract error. An absent provider is safe: capability queries return false, and timer/tick requests are inert.

Extension source continues to use the convenience surface vo_ext::host::{capability, timer, tick}. The Volang extension ABI recognizes and guarantees only the three entry-table functions described in section 3.2. An extension owner may also export an explicitly namespaced product C API; the runtime does not resolve or guarantee those additional symbols. Service setter/clearer symbols and process-wide bridge broadcasts are outside the ABI.

3.5 Call Results#

Rust implementations return vo_runtime::ffi::ExternResult. The current runtime variants include normal completion, scheduler blocking/yielding, host-event replay, closure callbacks, panic payloads, and registration failure. Across the native dylib boundary, #[vo_fn] generates an extern "C" trampoline that maps those variants to vo_runtime::ffi::ext_abi::RESULT_* u32 result codes. Complex payloads are copied through host callbacks.

pub enum ExternResult {
    Ok,
    Exit(i32),
    Yield,
    Block,
    WaitIo { token: u64 },
    HostEventWait { token: u64, delay_ms: u32 },
    HostEventWaitAndReplay { token: u64, source: HostEventReplaySource },
    Panic(String),
    NotRegistered(u32),
    CallClosure { closure_ref: GcRef, args: Vec<u64> },
}

Exit(i32) requires the provider and module contract to include MAY_EXIT. CallClosure requires MAY_CALL_CLOSURE_REPLAY; other suspended results have their corresponding precise MAY_* effect. Panic(String) and CallClosure { args } remain source-level Rust conveniences; the trampoline copies their bytes or slots before the extension drops its allocation.

WaitIo remains in the shared Rust enum for same-image runtime providers. A native #[vo_fn] trampoline treats that variant as a contract violation and returns RESULT_ABI_ERROR; extension authors use HostServices V2 together with HostEventWaitAndReplay. On an extension facade, try_io_mut, take_resume_io_token, and resume_io_token return None.

3.6 Type Mapping#

Rust TypeVo Type
i64int
f64float64
boolbool
&strstring
&[u8][]byte
InterfaceSlotany or an interface value
GcRefReference types

4. Extension Metadata in vo.mod#

4.1 Location and Ownership#

  • Extension metadata MUST be declared in the module root vo.mod.
  • It is part of the published source package.
  • A module root has exactly one human-authored module manifest: vo.mod.
  • If vo.mod has no [extension] table, the module declares no extension metadata.

4.2 Protocol-owned Tables#

Extension metadata uses these public runtime tables:

  • [extension]
  • [extension.native]
  • [extension.wasm]
  • [extension.web]
  • [extension.web.js]

Local production inputs use [build.native] and [build.wasm]. Files that are tracked inside the module boundary enter the authenticated source closure automatically. Public runtime fields and local build fields have separate ownership and never substitute for one another. The raw vo.mod, including the local build fields, remains part of source identity.

4.3 Canonical Shape#

format = 1
module = "example.com/acme/graphics"
version = "0.1.0"
vo = "0.1.0"

[extension]
name = "graphics"

[extension.native]
library = "acme_graphics"
targets = [
  "aarch64-apple-darwin",
  "x86_64-unknown-linux-gnu",
  "x86_64-pc-windows-msvc",
]

[extension.wasm]
kind = "standalone"
wasm = "graphics.wasm"

[extension.web]
capabilities = ["render"]

[extension.web.js]
renderer = "js/dist/studio_renderer.js"
protocol = "js/dist/studio_protocol.js"
host_bridge = "js/dist/studio_host_bridge.js"

[build.native]
kind = "cargo"
manifest = "rust/Cargo.toml"
package = "acme-graphics"

[build.wasm]
wasm = "web/pkg/graphics.wasm"

4.4 [extension]#

The [extension] table is required if the module declares extension metadata.

Fields:

  • name — required string; a stable runtime extension name for the module

Rules:

  • name MUST be a non-empty portable runtime name.
  • The extension MUST declare at least one native, WASM, or web runtime.
  • Unknown keys are errors.
  • Files needed by web runtime modules are declared through the appropriate [extension.web.js] mapping and must be tracked inside the source closure.

4.5 [extension.native]#

The optional [extension.native] table declares published native target support.

Fields:

  • library — optional portable logical library stem; defaults to [extension].name
  • targets — required non-empty array of unique canonical Rust target triples

Rules:

  • library is a stem, with no directory or platform extension.
  • Hyphens in the stem become underscores in the derived filename.
  • macOS derives lib<stem>.dylib, Linux and other Unix targets derive lib<stem>.so, and Windows derives <stem>.dll.
  • The logical stem can differ from the Cargo package name and Cargo library target. [build.native] selects those local inputs.
  • Every target is part of the immutable public support contract and requires one matching extension-native artifact in vo.release.json v2.

4.6 [extension.wasm]#

The [extension.wasm] table is optional. If present, the module declares support for the published target wasm32-unknown-unknown.

Fields:

  • kind — required; either standalone or bindgen
  • wasm — required; published WASM binary asset name
  • js — required only for bindgen; forbidden for standalone

Rules:

  • kind MUST be either standalone or bindgen.
  • wasm MUST be a non-empty file name, not a path.
  • If kind = "bindgen", js MUST be a non-empty file name.
  • If kind = "standalone", js MUST NOT be present.

4.7 Local build adapters#

[build.native] supplies one local native adapter:

[build.native]
kind = "cargo"
manifest = "rust/Cargo.toml"
package = "my-cargo-package" # optional

or:

[build.native]
kind = "prebuilt"
path = "dist/libmyext.dylib"

Cargo manifest and prebuilt path are normalized module-relative paths. The optional Cargo package selects a workspace member. Cargo must report the selected package's cdylib; a file found through unrelated path guessing cannot satisfy the adapter.

The Cargo manifest must be named Cargo.toml below a dedicated top-level directory. The first component of manifest reserves that complete tree as an opaque native build root; deeper manifest placement remains unrestricted. .git, .volang, .vo-cache, node_modules, and target cannot be used as the root under portable Unicode case folding; aliases such as .GIT and Target are rejected consistently on every host. *.vo, vo.mod, vo.lock, and vo.work are forbidden in the native source-input portion of the root. Generated and declared cache subtrees remain opaque. Language input capture skips the root without enumerating it. Cargo source, vendored trees, and unrelated links therefore remain deferred until analysis reaches the owning extension.

[build.wasm] maps local outputs to the logical public filenames:

[build.wasm]
wasm = "web/pkg/myext_bg.wasm"
js = "web/pkg/myext.js"

The js input is required exactly when the public WASM kind is bindgen. Build adapters are excluded from serialized public extension metadata, release identity, and consumer-side validation.

4.8 Artifact Mapping#

vo.mod determines the published artifact identities that must appear in vo.release.json. vo.lock v3 binds the raw release manifest digest and does not duplicate artifacts.

Manifest fieldPublished targetArtifact kindArtifact name
[extension.native].targets[*]target valueextension-nativefilename derived from library or extension name
[extension.wasm].wasmwasm32-unknown-unknownextension-wasmwasm
[extension.wasm].jswasm32-unknown-unknownextension-js-gluejs

Rules:

  • If a manifest field in the table above is present, the corresponding artifact MUST be published and recorded in vo.release.json.
  • Published artifact identities and byte digests MUST match the manifest and staged payload exactly.
  • Artifact names are part of release integrity, not dependency graph identity.

4.9 Validation Rules#

  • Extension metadata MAY declare [extension.native], [extension.wasm], or browser runtime metadata.
  • A module MAY declare both native and WASM support.
  • A module MAY declare only native support.
  • A module MAY declare only WASM support.
  • If a target is not declared, that target is unsupported for that module version.
  • If Rust-backed extension code exists for published use, the target-support contract MUST be expressed through [extension.native].targets.
  • A published release MUST include every artifact implied by the declared native target entries and the declared WASM section.
  • Build tools MUST fail on manifest/release mismatches rather than inferring missing target support.
  • [build.native] requires [extension.native]; [build.wasm] requires [extension.wasm].

4.10 Examples#

Native-only extension:

[extension]
name = "notify"

[extension.native]
library = "vo_notify"
targets = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]

[build.native]
kind = "cargo"
manifest = "rust/Cargo.toml"
package = "notify-capi"

Native plus wasm-bindgen extension:

[extension]
name = "voplay"

[extension.native]
library = "vo_voplay"
targets = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]

[extension.wasm]
kind = "bindgen"
wasm = "voplay_island_bg.wasm"
js = "voplay_island.js"

[build.native]
kind = "cargo"
manifest = "rust/Cargo.toml"

[build.wasm]
wasm = "web/pkg/voplay_island_bg.wasm"
js = "web/pkg/voplay_island.js"

5. Rust Module Workflow#

5.1 Vo-side Declaration#

# vo.mod
format = 1
module = "github.com/acme/myext"
version = "0.1.0"
vo = "0.1.0"

[extension]
name = "myext"

[extension.native]
library = "myext_capi"
targets = ["aarch64-apple-darwin"]

[build.native]
kind = "cargo"
manifest = "rust/Cargo.toml"
package = "myext"
package math

func Add(a, b int) int

5.2 Rust Crate#

[package]
name = "myext"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[package.metadata.vo]
vomod = "../vo.mod"

[dependencies]
vo-ext = { path = "../lang/crates/vo-ext" }
vo-runtime = { path = "../lang/crates/vo-runtime" }
use vo_ext::prelude::*;

#[vo_fn("myext/math", "Add")]
fn add(a: i64, b: i64) -> i64 {
    a + b
}

vo_ext::export_extensions!();

5.3 Local Development and Publication#

vo build .

Rules:

  • vo build . is the supported local project build command.
  • A local native extension MUST commit the Cargo.lock at its actual Cargo workspace root. For a standalone extension crate this is rust/Cargo.lock. The supported producer MUST discover the same workspace root used by Cargo, run dependency metadata and the build with --locked, fingerprint and snapshot that exact lock, and fail if it is absent or changes during the build. It MUST NOT create, restore, or substitute a member-local lock.
  • A development producer that redirects locked Volang git dependencies to a local checkout MUST inject patches only for package names already present in the lockfile's [[package]] graph or its existing [[patch.unused]] records. It MUST preserve that recorded unused-patch set, MUST NOT introduce a new [[patch.unused]] entry, and both patched and plain --locked Cargo contexts MUST leave the same lockfile byte-for-byte unchanged.
  • The producer MUST select the Cargo manifest and optional package named by [build.native]. A selected workspace member uses the enclosing workspace's actual lockfile and Cargo-reported target directory.
  • The Cargo target directory MUST be a dedicated generated-output subtree. It MUST NOT equal or contain the module root, selected package root, Cargo workspace root, or any reachable local Cargo package root.
  • Volang-owned build producers, including vo-engine and Quickplay artifact builders, MUST derive a deterministic content fingerprint from their complete declared Rust and Vo source trees, every reachable local Cargo package, vo.mod, vo.lock, the active vo.work, active workspace sources, the actual Cargo workspace lock, and relevant Cargo, toolchain, configuration, and build context.
  • Input capture MUST exclude the exact Cargo target directory reported by Cargo, regardless of that directory's name. Every reachable local Cargo package also excludes .git, .volang, .vo-cache, directories named target, and directories carrying a valid CACHEDIR.TAG signature. Cache contents may change concurrently without invalidating the input generation. Other directories remain inputs regardless of cache-like naming; node_modules is deliberately retained because build.rs and proc macros may consume checked-in JavaScript tooling. Broader module-tree scans may omit generated dependency and tool-cache trees.
  • A producer MUST pass that fingerprint as VO_FFI_SOURCE_FINGERPRINT. Adding, removing, or modifying any declared input MUST change the fingerprint, force proc-macro expansion, and invalidate a cached artifact. A producer that permits concurrent source edits MUST also bind a generation token into the complete reachable Rust compilation graph, verify that its input snapshot remained stable across the build, and use a new token when retrying an A-B-A transition.
  • A supported producer MUST execute Cargo even when its own native artifact marker is current, so Cargo and build.rs dependency declarations remain authoritative for external inputs.
  • A supported Cargo producer MUST consume Cargo's machine-readable artifact output and select the exact configured package and cdylib target. A pre-existing file at another path MUST NOT substitute for the reported Cargo artifact.
  • The selected Cargo package name and Cargo library target are local build facts. The public native artifact name is derived independently from [extension.native].library, falling back to [extension].name, plus the active target's platform prefix and suffix.
  • A kind = "prebuilt" adapter reads only the normalized [build.native].path and validates those bytes against the same public artifact identity. Those bytes are opened with stable-input checks only after analysis reaches the owning extension; they do not enter the base language snapshot.
  • Direct cargo build is a low-level integration path. When relevant source membership changes, its caller MUST set VO_FFI_SOURCE_FINGERPRINT to a new deterministic content fingerprint or run cargo clean before rebuilding.
  • Local workspace/native development MUST use [build.native] to produce or locate the host build output.
  • Published dependencies MUST use the exact artifacts authenticated through the vo.lock release digest and vo.release.json artifact bindings.
  • A published dependency's native library MUST NOT be rebuilt implicitly during a frozen build.

6. Browser WASM Extension Protocol v3#

This section defines the browser call boundary shared by standalone WASM extensions, wasm-bindgen extensions, Studio web, and other conforming hosts. Protocol v3 has its own epoch and does not inherit the native dynamic-library ABI version.

6.1 Identity, Ownership, and Version Gate#

Each source extern has the logical identity (canonical package path, exact function identifier). Browser hosts receive its canonical UTF-8 wire form:

vo1:<package-byte-length>:<package>:<function-byte-length>:<function>

Lengths are non-zero, unpadded decimal UTF-8 byte lengths. The complete wire name is at most 4096 bytes and must be consumed exactly. Hosts MUST reject an invalid prefix, malformed length, truncated field, invalid UTF-8, empty field, or trailing byte. Delimiter replacement, flattened names, and textual prefix aliases do not define extern identity. UTF-8 BOM bytes at the beginning of a field are ordinary U+FEFF data and MUST be preserved; a decoder MUST NOT strip them as a stream signature.

Lossless decoding does not confer semantic validity. Before a decoded name may enter bytecode verification, provider registration, or a native/WASM catalog, its package MUST satisfy the canonical package-identity rules in module.md. Its function MUST be one complete Unicode 16.0 Vo named declaration identifier; the blank identifier _ and all language keywords are rejected. Thus a structurally valid field containing U+FEFF remains byte-for-byte observable to the semantic validator and is rejected whenever it violates those rules.

The module path from the resolved manifest is the artifact's canonical owner. It MUST satisfy the canonical published-module rules in module.md. An owner claims its root package and packages beginning with the exact owner/ segment boundary. Descendant segments are case-sensitive and may contain portable Unicode. Each segment MUST be non-empty, at most 255 UTF-8 bytes, distinct from . and .., already normalized to NFC, free of boundary Unicode whitespace, and free of trailing dots, slashes, backslashes, @, control characters, Windows-forbidden punctuation, and reserved Windows device stems (including superscript-number COM/LPT aliases). The complete package path remains subject to the 4096-byte extern wire limit. When loaded nested modules both own a package prefix, the longest legal owner wins. [extension].name, artifact filenames, and JS glue filenames do not participate in this selection.

Every final browser WASM artifact MUST export:

uint32_t vo_ext_protocol_version(void);

The function MUST return exactly 3. Hosts check standalone instance exports or the instance exports returned by wasm-bindgen initialization before the artifact becomes visible to dispatch. The final Rust crate producing the .wasm file SHOULD invoke vo_ext::export_wasm_extension_protocol!() exactly once. Dependency extension crates MUST NOT invoke the macro because one linked artifact has one protocol identity and one version export. The helper uses a raw C export and does not require a direct wasm-bindgen dependency.

Extern dispatch decodes the canonical tuple, selects the owner, and derives one injective export key from the complete canonical wire name:

__vo_ext_ + lowercase_hex(UTF-8(canonical_encoded_extern_name))

The hexadecimal suffix includes every byte, with no hash and no truncation. Both standalone and wasm-bindgen artifacts MUST expose the function under this exact key. Root and descendant packages may therefore publish the same source function identifier without colliding. Hosts MUST NOT fall back to the decoded function identifier, the wire name itself, or any flattened/aliased spelling. If the selected deepest owner lacks the exact export key, the call fails as an artifact contract violation; the host MUST NOT retry a less-specific owner.

#[vo_fn] generates the statically linked typed provider entry and exact-key metadata. It does not synthesize the browser bytes serialization wrapper. A Rust artifact wrapper attaches #[vo_wasm_export], which validates the raw C signature, resolves the same authoritative vo.mod, verifies the source extern, emits the exact export name, and tracks all consulted metadata/source files for incremental builds:

#[vo_ext::vo_wasm_export("myext/math", "Add")]
pub extern "C" fn add_v3(
    input_ptr: u32,
    input_len: u32,
    output_len_ptr: u32,
) -> u32 {
    browser_v3::dispatch_add(input_ptr, input_len, output_len_ptr)
}

For wasm-bindgen, the corresponding wrapper is synchronous and bytes-only:

#[vo_ext::vo_wasm_bindgen_export("myext/math", "Add")]
pub fn add_v3_bindgen(input: &[u8]) -> Vec<u8> {
    browser_v3::dispatch_add_bytes(input)
}

The attribute emits #[wasm_bindgen(js_name = <exact-key>)]; generated glue therefore exposes glue[exactKey](Uint8Array) -> Uint8Array without a manual hex spelling. It accepts exactly &[u8] or Vec<u8> and returns exactly Vec<u8>; async wrappers and alternate value surfaces are rejected.

Incremental dependency markers cover Cargo.toml, the configured vo.mod, a selected vo.work, each selected workspace source's vo.mod, and every Vo source file consulted during macro expansion. The macro also observes VOWORK and VO_FFI_SOURCE_FINGERPRINT. Volang-owned producers follow section 5.3 and change the fingerprint for every addition, removal, or modification in their declared input set, including source-set membership. A caller that invokes Cargo directly MUST provide equivalent fingerprint invalidation or perform a clean rebuild when membership changes.

The artifact owns both dispatch implementations: they MUST implement the input and output encodings below, allocation ownership, full validation, and the exact control-frame rules. The export attributes supply identity and dependency tracking only.

6.2 Standalone Memory Boundary#

A standalone artifact exports:

void* vo_alloc(uint32_t size);
void vo_dealloc(void* ptr, uint32_t size);
void* <exact_export_key>(const void* input_ptr,
                         uint32_t input_len,
                         uint32_t* output_len_ptr);

Pointers and lengths use unsigned WebAssembly 32-bit semantics. The host validates every range against current linear-memory bounds before reading or writing it. The input allocation, four-byte output-length allocation, and returned output allocation MUST be pairwise disjoint. (output_ptr=0, output_len=0) is the sole null output and represents an empty output stream; a non-zero output length requires a non-null in-bounds pointer. A zero-length input may use (input_ptr=0, input_len=0); that pair denotes no allocation and MUST NOT be passed to vo_dealloc. Separately allocated zero-length input and output tokens may have the same numeric pointer because neither token covers a linear-memory byte; the host still releases both owned tokens exactly once.

The host owns every valid allocation returned for this call and attempts to release each owned, non-aliased allocation exactly once, including error paths. It copies output bytes before release. A trap, invalid pointer, overlapping range, malformed result, or deallocation failure is a deterministic extension contract failure. The Rust-to-JS import catches JS exceptions so they enter the VM contract-error channel instead of escaping as an uncaught WebAssembly trap.

6.3 Value and Control Encoding#

Input slots are encoded in declaration order:

scalar slot:         [u64 little-endian]
string/byte slot:    [u32 little-endian length][payload bytes]

The returned stream concatenates self-describing values:

0xE0                              nil error (two Vo slots)
0xE1 [u16 length] [UTF-8]         error string (two Vo slots)
0xE2 [u64 little-endian]          scalar value
0xE3 [u32 length] [bytes]         byte slice
0xE4                              nil reference
0xE5 [u32 length] [UTF-8]         string

The decoder MUST consume the full stream, validate all integer ranges and UTF-8 fields, and produce exactly the declared return-slot width. An empty stream is valid only for a zero-return extern.

A wasm-bindgen extern wrapper MUST return this tagged stream synchronously as a Uint8Array. Strings, promises, and other JavaScript values do not satisfy the protocol boundary.

Protocol v3 defines these control frames:

0x01 [source u8] [replay-codec u8]   suspend and replay; exactly 3 bytes
0x02 [payload...]                    host output
0x03                                 display-pulse wait; exactly 1 byte

Suspend sources are 0 = GUI event, 1 = fetch, and 2 = extension. Replay codecs are 0 = invoke the same exact extern with raw resume bytes and 1 = decode [i32 little-endian handler][UTF-8 payload] as (int, string). Only (GUI event, codec 1), (fetch, codec 0), and (extension, codec 0) are valid. The runtime records this metadata under the canonical extern identity when suspension begins and requires the same metadata at replay. Function-name suffixes and JS-side semantic guesses MUST NOT select a replay source or codec.

6.4 Load and Cache Lifecycle#

Loading the same canonical owner with byte-identical WASM and byte-identical decoded JS glue source is idempotent. Transport URLs do not define artifact identity. Loading different bytes or glue under an already-live owner MUST fail without mutating or disposing the live artifact. Intentional replacement requires explicit disposal followed by a new load. Disposal, whole-runtime reset, and saved-state restoration keep owner and replay metadata synchronized with the JavaScript artifact maps.

Disposal preflights lifecycle-counter capacity, then the synchronous Rust owner removal MUST succeed before JavaScript changes the owner generation, cancels a pending load, or removes any active dispatch entry. If Rust removal fails, the complete JavaScript transaction and its resources remain live. After successful Rust removal, map deletion is non-throwing and resource cleanup is best-effort. Whole-runtime reset follows the same transaction boundary.

Each newly published artifact receives a monotonically increasing generation. Setup synchronously returns an opaque artifact token, a per-caller lease token, and a readiness promise. Fulfillment means the instance is prepared and remains absent from every active JavaScript dispatch map. The Rust continuation MUST confirm the prepared token, record the owner generation, and synchronously commit the prepared artifact before yielding. Commit is the only operation that moves a prepared artifact into active dispatch maps. If the loading future is cancelled or commit fails, its lease is aborted; the last uncommitted lease destroys the prepared artifact and leaves the Rust owner set unchanged. An identical concurrent load has its own lease on the same transaction, so cancelling one waiter does not cancel the others. The host also retains the returned handle identity in a private weak map. Rust arms handle-identity cancellation before reading any public handle field, so a missing, throwing, or ill-typed token/readiness field still releases the exact lease created by setup. Generation and lease counters MUST NOT wrap. If final-lease cancellation cannot advance an exhausted owner generation, the host destroys the prepared artifact and rejects further loads for that owner until a successful whole-runtime reset; the exhausted artifact token is never reused.

A VM resolution freezes the selected (owner, artifact generation) for every extension extern. The bridge validates that binding both immediately before and immediately after every JavaScript export call, including replay. Output and newly returned suspend metadata MUST be discarded if a synchronous export disposes or replaces its owner or publishes a deeper owner. Lifecycle changes to an unrelated owner do not invalidate the frozen binding.

A bindgen reload after disposal MUST evaluate a fresh glue-module identity; an ES-module cache entry that still closes over the disposed WebAssembly instance cannot satisfy the new load.

Only one load transaction may be pending for an owner. A concurrent request for the identical artifact joins that transaction; a concurrent request for a different artifact fails. Disposal or whole-runtime reset invalidates pending transactions, and an invalidated asynchronous result MUST NOT publish an instance after the lifecycle operation returns. Cancellation of the final lease has the same effect and a later load of the same owner starts a fresh transaction.

Disposal first forgets its Rust owner generation, then removes the artifact from JavaScript dispatch, then invokes extension cleanup hooks. A reentrant call from cleanup therefore cannot reach the disposed artifact. A standalone artifact's outstanding host timers, intervals, animation frames, and game loops are scoped to that artifact and MUST be cancelled when it is disposed; one artifact MUST NOT own or cancel another artifact's host resources.

A nested VM save/restore scope may change replay metadata only. Loading, disposing, or resetting extension artifacts inside that scope is a contract failure because a Rust snapshot cannot roll back JavaScript instances. Restore MUST verify the owner lifecycle epoch and active artifact generations before restoring replay metadata; it MUST NOT overwrite a newer live owner set.

Browser compile caches MUST include a compiler-authority and protocol schema epoch. The Studio VFS compile cache epoch is 5; artifacts cached under an earlier epoch are not reusable.

Built with Volang UI · A work in progress, made to be explored.
Opening Studio…