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:

CostWhere it showed up
A sandbox you must design and defendThreat model, 15 controls, 9 non-goals
Budgets, and tuning them per workloadLab 23
Determinism, deliberately engineeredADRs 008, 009; five design decisions
Diagnostics for non-engineersLab 24
A review process for policy changesNot code review; not config review. You must invent it
Runtime errors in productionA policy that returns nil now fails a request
Performance you cannot seeYour profiler shows Vm::run, not the policy's hot line
A second language your team must knowAnd its divergences from the one it resembles
The runtime itselfWhether 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:

CaseWhy a language wins
Ranking and pricing policies that change weekly and are written by domain peopleArbitrary logic, high change rate, non-engineer authors
Game logic — quests, abilities, AI behaviorsThe canonical case; this is why Lua exists
User-authored automations — filters, triggers, transformationsThe shapes are genuinely unbounded
Extension points in a tool — editor plugins, build rules, query hooksUsers' needs exceed your imagination, by design
Rapid experimentation where a deploy cycle is the bottleneckThe 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

CaseWhat to do instead
"It would be nice if this were configurable"Nothing. Wait until it is actually needed.
One number that changes twice a yearA constant, or TOML
Logic that changes with the schemaRust. It needs a deploy anyway
The hot inner loopRust. A script in a per-item loop is the wrong shape — see below
Untrusted, anonymous authorsA process boundary or WASM. Not an in-process interpreter
Anything security-criticalRust, 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)RhaiRuneJavaScript (QuickJS/V8)WASMA DSL
Maturity30 yearsgoodyoungenormousgoodyours
Sizetinytinysmalllargemediumtiny
Sandboxingmanual, well-understoodbuilt-inbuilt-instrong (V8 isolates)strongesttrivial
Rust integrationC FFI via a bindingnativenativeFFInative-ishnative
Author familiarityhigh in gameslowlowhighestn/azero
Determinismneeds workneeds workneeds workneeds workgoodfree
Bill of materialsa C librarya Rust cratea Rust cratea large C++ enginea runtime50 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_std or 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

  1. Give the four questions in order, and the answer that stops the sequence at each.
  2. What is the test for "configuration rather than a language"?
  3. Write the TOML DSL for "boost by X if age < Y hours", and say what it costs to interpret.
  4. Name six costs of embedding that a config file does not have.
  5. Why is a script call per candidate the wrong shape, and which earlier tradeoff does that echo?
  6. Which row usually decides the language choice, and why does it dwarf the technical differences?
  7. When is WASM the right answer, and what does it cost?
  8. Give the three legitimate reasons to build a runtime rather than adopt one.

Next: The Evaluation Rubric.