Host Objects and Userdata
Three concepts: userdata, the copy-versus-borrow decision, and capability boundaries.
This is how article.semantic_score works when Article is a Rust struct — and it is where the
capstone's performance is decided, because the alternative (marshaling every candidate into a table)
allocates thousands of objects per request.
Concept 1: Userdata
1. Concept
Userdata is a heap object owned by the engine whose contents are an opaque Rust value plus a metatable. Scripts can hold it, pass it, store it in tables, and index it — and cannot see inside it except through the methods the host provides.
2. Problem
#![allow(unused)] fn main() { struct Article { id: u64, semantic_score: f64, age_hours: f64, topic: String } }
A script needs article.semantic_score. Three bad answers and one good one:
| Approach | Problem |
|---|---|
| Pass a raw pointer | Use-after-free with a scripting language on top |
Pass a &Article | The lifetime infects Value, which infects everything |
| Marshal into a table | 4 allocations per article × 10,000 candidates per request |
| Userdata | One engine-owned object; fields read on demand |
3. Mental model
Userdata is a box with a type tag and a metatable. The engine owns the box and the collector traces it. The script holds a handle. Field access goes through
__index— the same path tables use, which is why the machinery already exists.
Value::UserData(GcRef)
│
heap ────▼──────────────────────────────────────────┐
[42] UserData { │
type_id: TypeId::of::<Article>(), │ ← the tag: downcast safely
data: Box<dyn UserDataValue>, │ ← the host's Article
meta: Some(GcRef<Table>), │ ← per-TYPE, shared
} │
───────────────────────────────────────────────────-─┘
│
Article's metatable ───▼──── __index = native fn: (ud, key) → field value
4. Implementation
#![allow(unused)] fn main() { pub trait UserData: 'static { /// Register this type's methods and fields. Called ONCE per type per engine. fn build(builder: &mut UserDataBuilder<Self>) where Self: Sized; /// Report GC edges. Default: none. Override if the type holds Ember Values. fn trace(&self, _grey: &mut Vec<Handle>) {} } impl UserData for Article { fn build(b: &mut UserDataBuilder<Self>) { b.field("id", |a| a.id as i64); b.field("semantic_score", |a| a.semantic_score); b.field("age_hours", |a| a.age_hours); b.field("topic", |a| a.topic.as_str()); b.method("is_fresh", |a, _ctx, _args| Ok(vec![Value::Boolean(a.age_hours < 6.0)])); b.meta_tostring(|a| format!("Article({})", a.id)); } } }
#![allow(unused)] fn main() { // Engine side: let handle = engine.create_userdata(article)?; // MOVES the Article into the engine engine.set_global("article", handle)?; }
Three properties of that design:
- The metatable is per type, not per object. One
Articlemetatable is built once and shared by every article, exactly as strings share one. Building it per object would allocate a table per candidate — the very cost userdata exists to avoid. type_idmakes downcasting safe. A native method registered forArticlereceiving aVector3gets a typed error, not a transmute. This isstd::any::TypeId, and it is the reason the trait requires'static.traceis not optional if the userdata holdsValues. A host object storing an Ember callback is a GC edge, and the collector cannot discover it. Default to none, and say loudly what overriding it is for.
5. Alternatives
| Option | What the script gets | Cost |
|---|---|---|
| A. Full userdata (ours) | an opaque object with a metatable | One object per host value; field access is a native call |
| B. Light userdata (Lua has this) | a bare pointer, no metatable, no ownership | Fast, and the host must guarantee the lifetime by hand. Lua's own docs call it unmanaged |
| C. Marshal to a table | a plain Ember table, copied | Simple, ergonomic; O(fields) allocations per object |
D. A proxy table with __index | a table whose __index calls back to the host | No userdata type needed; an extra table per object |
Lua's light userdata is worth knowing about precisely because Ember refuses it: it is a raw void*
with no metatable and no lifetime management, and it exists for C hosts that need to pass an address
through. In Rust it would be a raw pointer in a safe API, which is exactly the thing the
ownership boundary forbids. Note the
omission in appendix/lua-differences.md with that reason.
6. Decision
A only. No light userdata. Option C stays available and is the right answer for small, short-lived, mostly-read data — the choice is Concept 2.
7. Tradeoffs
| We gain | We lose |
|---|---|
Host objects with no copying, no lifetimes in Value | Field access is a native call, not a table probe |
Type-safe downcasting via TypeId | The host must move ownership into the engine |
| The collector manages the lifetime | A Box<dyn UserDataValue> per object |
8. Production concerns
- The engine owns it, so the host cannot mutate it.
create_userdatamoves the value in. A host that needs to keep mutating must either take it back out (Engine::take_userdata, which invalidates the script's handle) or store a shared handle to its own storage (Arc<Mutex<_>>, with all that implies). Say which your API supports; ambiguity here becomes aliasing bugs. - Userdata is a capability. A host object exposing
db.execute(sql)grants the script database access, whatever the standard library does. The capability audit in the threat model must enumerate host objects, not just globals. - Dropping order at engine shutdown. When the
Engineis dropped, every userdata'sDropruns. If one of them re-enters the engine — aDropimpl that calls a script callback — it re-enters a half-destroyed runtime. Ember forbids it structurally:Dropfor userdata gets noCtx. State it, because someone will want a finalizer, and that is the same argument that excluded__gc. tracebugs are collector bugs. A host object holding an EmberValuewithout tracing it produces stale handles under memory pressure — in host code, which is the worst place to debug it. Maketraceprominent in the docs and test host objects under--gc-stress.
9. References
- Lua 5.4 Reference Manual §2.1 (userdata) and §4.1.5 on light versus full userdata.
mlua'sUserDatatrait andUserDataMethods— the closest analogue, including its handling of&selfversus&mut selfmethods, which is a real design question Ember simplifies by making fields read-only by default.- V8's
ObjectTemplateand internal fields, for the same idea at scale.
Concept 2: Copy versus Borrow
1–3. Concept, problem, mental model
Copy the data if it is small, short-lived, and mostly read in full. Expose it as userdata if it is large, long-lived, or read selectively.
The capstone makes this concrete. A request ranks 10,000 candidate articles; the policy reads two or three fields from each.
| Approach | Allocations per request | Script-side cost |
|---|---|---|
Marshal each Article into a table | 10,000 tables + ~40,000 strings | table probe per field: fast |
One userdata per Article | 10,000 userdata, no field copies | native call per field: slower per access, but only for fields actually read |
| One userdata for the whole candidate list, indexed lazily | 1 | one native call per access |
The third row is the answer for the capstone, and it is not obvious until you count. Marshaling is O(fields exposed); userdata is O(fields read). When the policy reads 3 of 12 fields, that is a 4× difference in allocation before any ranking happens.
4. Implementation
#![allow(unused)] fn main() { // The capstone's shape: the candidate SET is one userdata; indexing it yields // per-article userdata that borrow (by index) into the same host storage. struct Candidates { articles: Vec<Article> } impl UserData for Candidates { fn build(b: &mut UserDataBuilder<Self>) { b.meta_len(|c| c.articles.len() as i64); b.meta_index_int(|c, i, ctx| { // candidates[i] let idx = (i - 1) as usize; if idx >= c.articles.len() { return Ok(Value::Nil); } // An ArticleRef is (candidates_handle, index) — no copy, no lifetime. ctx.create_userdata(ArticleRef { owner: ctx.self_handle(), index: idx }) }); } } }
ArticleRef is an index, not a pointer, and that is the whole trick. It is the same move as
GcRef
one layer up: a handle into storage the engine can validate, instead of an address it cannot.
5–8. Alternatives, decision, production concerns
| Option | Lifetime safety | Cost |
|---|---|---|
| A. Move the data into the engine (ours) | total — the engine owns it | The host gives up mutation |
B. Arc<T> shared between host and engine | safe, shared | Needs Send/Sync on T; interior mutability for writes |
| C. Index handles into host-owned storage (ours, for collections) | safe if the index is validated | The host must not shrink the storage while the script runs |
| D. Raw pointer plus "don't do that" | none | The Lua light-userdata answer. Not available in a safe API |
Decision: A for individual objects, C for collections, with the host-side rule written down: the backing storage must not be mutated while a script holds indices into it. In the capstone, the candidate list is built before evaluation and dropped after, so the rule is structurally satisfied — which is the right way to satisfy a rule.
Production concerns:
- Validate the index every time.
articles[idx]with an uncheckedidxfrom a script is an out-of-bounds read. Rust will panic rather than corrupt, which is better and still a host crash. Returnnilor an error. - A userdata handle can outlive the request. A script that stashes
candidatesin a global keeps it alive across evaluations. If the host expected the data to be dropped, it is now leaked — and worse, the next request's script can read the previous request's candidates. Swap the globals table per evaluation (ADR-012), and the capstone does exactly that. - Field access cost. A native call per field is more expensive than a table probe. Measure it in
the capstone before optimizing; if it dominates,
an inline cache on the userdata
__indexsite is the fix, and it is the same mechanism as for tables.
Concept 3: Capability Boundaries
1–3. Concept, problem, mental model
Every method on a host object is a capability grant. The standard library is not the attack surface; the union of the standard library and every registered function and every userdata method is.
-- The standard library grants nothing dangerous. This still deletes the database.
db:execute("DROP TABLE articles")
4. Implementation
The audit that makes this real, and it belongs in the test suite rather than in a document:
#![allow(unused)] fn main() { #[test] fn the_full_capability_surface_is_enumerated_and_reviewed() { let engine = build_production_engine(); let mut surface: Vec<String> = engine.enumerate_globals(); for ud in engine.registered_userdata_types() { surface.extend(ud.methods().map(|m| format!("{}:{}", ud.name(), m))); } surface.sort(); // A golden file, reviewed like any other diff. Adding a capability CHANGES // this file, which makes the grant visible in code review instead of // invisible in a builder call three modules away. assert_eq!(surface, read_golden("tests/golden/capability-surface.txt")); } }
That test is the single most valuable security control in Section 5. Not because it prevents anything — it prevents nothing — but because it makes every capability grant appear in a diff.
5–8. Alternatives, decision, production concerns
| Option | How capabilities are controlled |
|---|---|
| A. Explicit grant + an enumerated surface test (ours) | Nothing is available unless registered; the surface is a golden file |
B. Metatable protection (__metatable, read-only tables) | Weak: a script can setmetatable again, or reach the original through another path |
| C. Remove dangerous globals after loading (the classic Lua sandbox) | Fragile: misses string.dump, load, debug.*, and anything reachable transitively |
| D. OS-level isolation (process, seccomp, WASM) | The only thing that survives a runtime bug — and it is outside the runtime |
Decision: A, and say clearly that A is not D. Ember's capability model defends against scripts doing what you did not grant. It does not defend against bugs in the runtime itself. A host that needs the second one needs a process boundary, and the threat model says so in those words.
Production concerns:
- Transitive reachability. A registered function returning a table the host also holds gives the script write access to it. Return copies, or return userdata with read-only accessors.
- Capabilities are per-engine, so per-tenant means per-engine. One engine with a tenant id in a global is not isolation; it is a bug waiting for a script that changes the global.
- The
debuglibrary does not exist in Ember, and that is a capability decision. Lua'sdebugcan read locals, upvalues, and other coroutines' stacks — it defeats every sandbox built on metatables. Note it as a divergence with the reason.
9. References
- Lua's
lua_setuservalue/lua_getuservalue, and theluaL_newmetatableregistry pattern. - The
lua-userswiki sandboxing page — read it, then read the many follow-ups explaining why each version was escapable. It is the best available argument for option A over C. - Capability-based security literature: the E language, Capsicum, and the general "no ambient authority" principle that this chapter is an instance of.
Things to Notice
- Userdata is a handle into engine-owned storage, and
ArticleRefis a handle into host-owned storage. The same pattern at two layers, for the same reason: an index can be validated, an address cannot. - Metatables are per type, not per object. Getting this wrong reintroduces the allocation cost userdata exists to remove.
- Marshaling is O(fields exposed); userdata is O(fields read). Count before choosing.
traceis where a host can break the collector. Make it prominent.- Every host method is a capability, and the capability surface is the union — not the standard library.
- The enumerated-surface golden test prevents nothing and is the best control in the section, because it moves grants into code review.
- Runtime capability control is not process isolation. Say which one you have.
Validation / Self-check
- Give the four ways to expose a Rust struct to a script and the specific problem with each of the three Ember rejects.
- Why is the metatable per type rather than per object? What does the alternative cost?
- What does
TypeIdbuy, and why doesUserData: 'staticfollow? - When should a host marshal into a table instead of using userdata? State the rule as a complexity comparison.
- Why is
ArticleRefan index rather than a reference? Where have you seen that move before? - What must be true of the host's backing storage while a script holds indices into it, and how does the capstone satisfy it structurally?
- Why does userdata
Dropget noCtx? Which earlier decision is that the same as? - What is the full capability surface, and why is a golden test of it valuable despite preventing nothing?
- Why is Lua's light userdata absent from Ember?
Next: The Standard Library.