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.modextension metadata schema - explicit published target declarations
- mapping from manifest fields to
vo.release.jsonartifact 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 Type | Vo Type |
|---|---|
i64 | int |
f64 | float64 |
bool | bool |
&str | string |
&[u8] | []byte |
InterfaceSlot | any or an interface value |
GcRef | Reference 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.modhas 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:
nameMUST 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].nametargets— required non-empty array of unique canonical Rust target triples
Rules:
libraryis 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 derivelib<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-nativeartifact invo.release.jsonv2.
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; eitherstandaloneorbindgenwasm— required; published WASM binary asset namejs— required only forbindgen; forbidden forstandalone
Rules:
kindMUST be eitherstandaloneorbindgen.wasmMUST be a non-empty file name, not a path.- If
kind = "bindgen",jsMUST be a non-empty file name. - If
kind = "standalone",jsMUST 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 field | Published target | Artifact kind | Artifact name |
|---|---|---|---|
[extension.native].targets[*] | target value | extension-native | filename derived from library or extension name |
[extension.wasm].wasm | wasm32-unknown-unknown | extension-wasm | wasm |
[extension.wasm].js | wasm32-unknown-unknown | extension-js-glue | js |
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.lockat its actual Cargo workspace root. For a standalone extension crate this isrust/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--lockedCargo 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 activevo.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 namedtarget, and directories carrying a validCACHEDIR.TAGsignature. Cache contents may change concurrently without invalidating the input generation. Other directories remain inputs regardless of cache-like naming;node_modulesis deliberately retained becausebuild.rsand 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.rsdependency 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
cdylibtarget. 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].pathand 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 buildis a low-level integration path. When relevant source membership changes, its caller MUST setVO_FFI_SOURCE_FINGERPRINTto a new deterministic content fingerprint or runcargo cleanbefore 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.lockrelease digest andvo.release.jsonartifact 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.