When Not to Embed a Language
You have spent four months building a scripting runtime. This chapter argues against using it.
That is not a joke and it is not modesty. The most valuable thing an engineer can do with a tool they understand deeply is say precisely when it is the wrong tool — and you are now one of the few people qualified to say it about this one, because you know exactly what it costs.
The Three Options
Almost every "we need this to be configurable" conversation has the same three answers:
HARD-CODED IN RUST CONFIGURATION (TOML/JSON) AN EMBEDDED LANGUAGE
────────────────── ───────────────────────── ────────────────────
fastest fast slower
type-checked schema-checked (maybe) checked at run time
change = deploy change = config push change = policy push
any logic values and simple structure any logic
reviewed as code reviewed as data reviewed as... what?
one blast radius per-key blast radius per-policy blast radius
no new failure modes parse errors runtime errors, budgets, sandboxing
The last row is the one people skip, and it is where the cost lives.
The Decision, as a Sequence of Questions
Ask them in order. Stop at the first "yes".
1. Does the change need a deploy anyway?
If changing this value requires a coordinated rollout with something else — a schema migration, a new data field, a client update — then a policy push buys you nothing. Hard-code it.
2. Is the space of changes a finite set of values?
BOOST_FRESH = 1.4. A threshold. A list of allowed topics. A weight per category.
Use TOML. A config file is schema-checkable, reviewable as data, diffable, typo-detectable at load, and cannot loop forever. Every one of those is a property an embedded language takes away from you and makes you rebuild.
The test: can you write the JSON Schema? If yes, you want configuration, not a language.
3. Is the space of changes a finite set of shapes?
"Boost by X if the article is younger than Y hours." "Penalize by X if the user has seen it."
Use a small DSL — a data structure, not a language.
[[rules]]
when = { field = "age_hours", op = "<", value = 6 }
then = { op = "multiply", value = 1.4 }
[[rules]]
when = { field = "seen", op = "==", value = true }
then = { op = "multiply", value = 0.5 }
Fifty lines of Rust interprets that. It is schema-checked, it terminates by construction, it needs no sandbox, no budget, no GC, and no threat model. This is the answer people skip past on their way to embedding Lua, and it is right far more often than it is chosen.
4. Do the changes need arbitrary logic — and change often?
Only now.
"Boost articles whose topic matches the user's second preference, but only on weekends, and only for users in experiment arm B, and cap the total boost at 2×, unless the article is from a followed author."
You cannot schema that. Every new rule is a new shape. And it changes weekly, written by people who are not on the deploy rotation.
Now embed a language. And now you owe: a sandbox, budgets, a threat model, diagnostics good enough for non-engineers, a review process for something that is neither code nor data, and an on-call story for "the policy is wrong at 3am".
What Embedding Actually Costs
The bill, itemized, from this curriculum:
| Cost | Where it showed up |
|---|---|
| A sandbox you must design and defend | Threat model, 15 controls, 9 non-goals |
| Budgets, and tuning them per workload | Lab 23 |
| Determinism, deliberately engineered | ADRs 008, 009; five design decisions |
| Diagnostics for non-engineers | Lab 24 |
| A review process for policy changes | Not code review; not config review. You must invent it |
| Runtime errors in production | A policy that returns nil now fails a request |
| Performance you cannot see | Your profiler shows Vm::run, not the policy's hot line |
| A second language your team must know | And its divergences from the one it resembles |
| The runtime itself | Whether you build it or adopt mlua/rhai/rune |
The last row is the one to be honest about. If you adopt a runtime rather than building one, you
inherit its threat model, its bugs, and its maintenance — and you should read its limitations.md
with the same scepticism this curriculum taught you to apply to your own.
When Embedding Is Clearly Right
Not a rhetorical exercise; these are real:
| Case | Why a language wins |
|---|---|
| Ranking and pricing policies that change weekly and are written by domain people | Arbitrary logic, high change rate, non-engineer authors |
| Game logic — quests, abilities, AI behaviors | The canonical case; this is why Lua exists |
| User-authored automations — filters, triggers, transformations | The shapes are genuinely unbounded |
| Extension points in a tool — editor plugins, build rules, query hooks | Users' needs exceed your imagination, by design |
| Rapid experimentation where a deploy cycle is the bottleneck | The deploy cycle is the cost being removed |
The common thread: the set of things someone will want to express is genuinely unbounded, and the people expressing them are not the people who deploy.
When It Is Clearly Wrong
| Case | What to do instead |
|---|---|
| "It would be nice if this were configurable" | Nothing. Wait until it is actually needed. |
| One number that changes twice a year | A constant, or TOML |
| Logic that changes with the schema | Rust. It needs a deploy anyway |
| The hot inner loop | Rust. A script in a per-item loop is the wrong shape — see below |
| Untrusted, anonymous authors | A process boundary or WASM. Not an in-process interpreter |
| Anything security-critical | Rust, reviewed, with tests. A policy is not a security control |
| "So we don't have to recompile" | Compile times are a build-system problem |
The hot-loop row deserves a note, because it is the one that bites in the capstone's own domain. Scoring 10,000 candidates with a script call per candidate means 10,000 crossings of the host boundary. The right shape is one call that returns 10,000 scores, or — if the policy is simple enough — a compiled expression evaluated in Rust. Notice that this is an argument for a DSL again, arriving from the performance direction rather than the safety one.
Why Lua Rather Than Something Else
If the answer is "embed a language", the next question is which. The honest comparison:
Lua (via mlua) | Rhai | Rune | JavaScript (QuickJS/V8) | WASM | A DSL | |
|---|---|---|---|---|---|---|
| Maturity | 30 years | good | young | enormous | good | yours |
| Size | tiny | tiny | small | large | medium | tiny |
| Sandboxing | manual, well-understood | built-in | built-in | strong (V8 isolates) | strongest | trivial |
| Rust integration | C FFI via a binding | native | native | FFI | native-ish | native |
| Author familiarity | high in games | low | low | highest | n/a | zero |
| Determinism | needs work | needs work | needs work | needs work | good | free |
| Bill of materials | a C library | a Rust crate | a Rust crate | a large C++ engine | a runtime | 50 lines |
The row that usually decides it is "author familiarity". If your policy authors are data scientists, they know Python. If they are game designers, they know Lua. If they are web engineers, they know JavaScript. A language nobody on the team knows is a language nobody will write good policies in, and that cost dwarfs every technical difference in the table.
And WASM is worth taking seriously for the hostile case: it is the only row where the sandboxing story is genuinely strong, and it lets authors use whatever language compiles to it. Its cost is a much heavier host integration and a marshaling boundary that makes fine-grained data access expensive — which is the same tradeoff as marshaling versus userdata, one level up.
Why Build One Instead of Adopting One
Almost never, for production. The honest list:
Legitimate reasons:
- You need semantics no existing runtime has (Ember's determinism guarantees, for instance).
- You need a
no_stdor WASM-target runtime and the candidates do not build. - The dependency is unacceptable — a C library in a memory-safe codebase, a large engine in a small binary.
- You are learning. Which is what this curriculum was.
Not legitimate:
- "It'll be simpler." It will not be. You have now measured this.
- "I want full control." You want it until the first metatable bug.
- "Ours will be faster." Against LuaJIT? No.
The Sentence to Take Away
Embed a language when the set of expressible things is genuinely unbounded and the people expressing them are not the people who deploy. Otherwise use configuration, or a small DSL, or Rust — and know exactly which of the three you are choosing and why.
Being able to say that, and to say what each option costs, is what four months of building a runtime bought you. The runtime is the artifact; the judgment is the outcome.
Deliverable
Write your own version of this chapter, in your own words, in docs/when-to-embed.md, including:
- A decision you have actually faced (or are facing) at work, walked through the four questions.
- The DSL you would write instead, for that case, in TOML or JSON.
- An honest estimate of what embedding would have cost, using this curriculum's bill of materials.
One page. If you cannot argue against your own project in one page, you do not understand its boundaries yet.
Validation / Self-check
- Give the four questions in order, and the answer that stops the sequence at each.
- What is the test for "configuration rather than a language"?
- Write the TOML DSL for "boost by X if age < Y hours", and say what it costs to interpret.
- Name six costs of embedding that a config file does not have.
- Why is a script call per candidate the wrong shape, and which earlier tradeoff does that echo?
- Which row usually decides the language choice, and why does it dwarf the technical differences?
- When is WASM the right answer, and what does it cost?
- Give the three legitimate reasons to build a runtime rather than adopt one.
Next: The Evaluation Rubric.