Skip to main content

Stof 0.10: Safe by Default, Errors You Can Read, and a Language Server

CJ Cummings
Creator and maintainer of Stof, co-founder & CEO of Limitr

Stof 0.10 is out. It's the biggest release since Stof started, and almost all of it serves one goal: making it safe and easy to run logic you didn't write, whether it came from a model, a customer, or another team.

That meant tightening the sandbox, making every error something a person or a model can read and fix, closing off behaviors that quietly produced wrong answers, and giving you better tools to write Stof yourself. This post covers 0.10.0 through 0.10.6, along with stof-cli 0.13 and version 0.3 of the VS Code extension.

npm i @formata/stof@latest # TypeScript / JavaScript
pip install -U stof # Python
cargo add stof # Rust
cargo install stof-cli # CLI, including the language server

Most documents run unchanged, but a few fixes are breaking. Read Breaking Changes before you upgrade.

Sandboxed by Default​

Before 0.10, an embedded document in Rust or Python could read and write files, read environment variables, and make network requests unless the host removed those libraries. That's the wrong default for logic you don't fully trust.

Now an embedded document reaches nothing outside itself unless your app opts in:

const doc = await StofDoc.new(rulesFromAModel);

doc.allowHttp(['api.example.com']); // the Http library, for these hosts only (0.10.4)
doc.setMaxExecutionTime(50); // every call fails after 50ms (0.10.4)
  • Files and environment are off until allow_system() (Rust, Python).
  • Network is off until allow_http() (Rust, Python) or allowHttp() (JavaScript). In JavaScript, allowHttp() also gives documents Http.fetch, backed by the platform's fetch, so you don't need your own.
  • An allowlist limits requests to the hosts you name. A request to any other host, or a redirect to one, fails.
  • Time limits are now yours to set. The default is still 2 minutes, and a runaway loop fails that one call and nothing else.

The stof CLI turns everything on, since it runs documents on your own machine. See Using Stof in Your App.

Errors You Can Read​

Errors were the most common complaint about Stof, and they're the thing that matters most when a model writes the code: an error it can read is an error it can fix.

Runtime errors now say what went wrong in plain language, show the line, and give the Stof call stack:

error: cannot multiply null: the right value is null or missing (use ?? to give a default)
--> pricing.stof:4:9
|
4 | units * self.rat
| ^
at root.plan.total (pricing.stof:4:9)
at root.main (pricing.stof:10:5)

Parse errors show the file, line, and column with a caret. Argument errors say exactly what's wrong: missing argument 'units' for total(units: int, rate?: float). And names that can't be anything are caught when the document is parsed, before they turn into a confusing null:

warning: unknown name 'team' in fn root.main (main.stof:4:9): not a parameter, variable,
or root, so it is always null (use self.team for a field)

Your app gets the same messages and call stacks from call and run, in every language.

Fewer Silent Wrong Answers​

A few long-standing behaviors gave wrong results without complaint. They're now fixed or reported:

  • Math with null is an error. 3 * null used to be 3. Now it throws, so a misspelled field can't become a wrong price. Use ?? for defaults.
  • Functions have their own scope. A function could read its caller's variables, so a typo could quietly bind to the wrong value. Now named functions see only their parameters and their own variables. Arrow functions still see the function they're written in.
  • Paths start at a root. team.name used to find any object named team anywhere in the document. Now it means the root named team, and the warning above catches the mistake.
  • Numbers behave. Number methods return a new value instead of changing the original. Integer overflow becomes a float instead of wrapping negative, division by zero gives infinity instead of crashing, and unit conversions use exact factors.
  • typeof binds tightly (0.10.2). typeof x == 'obj' is now (typeof x) == 'obj', as you'd expect.

Temporary Objects with using​

Objects in a Stof document stay until something removes them. That's fine for a document you run once, but a policy that runs on every request for months can pile up temporary objects. 0.10.3 adds using: declare a temporary object with it, and it's dropped when its block or function ends, however it ends:

using.stofPlayground ↗
orders: {}

fn check(total: float) -> bool {
  using order = new { total: total, tax: total * 0.2 } on self.orders;
  order.total + order.tax < 1000
}

#[main]
fn main() {
  pln(self.check(500));
  pln(self.check(900));
  pln(self.orders.children().len());   // 0: both were dropped
}
Output

self.handle(using new { .. }) does the same for an object passed straight into a call. And stof test --leaks runs each test on its own and fails any test that leaves objects behind, saying where they are. See Memory Management.

Destructuring​

0.10.5 adds destructuring, which a data language really should have. Take values apart into variables in one step, from objects, maps, lists, and tuples, and in loops:

destructuring.stofPlayground ↗
user: {
  name: 'Ada'
  age: 36
  address: { city: 'London' }
}

#[main]
fn main() {
  const { name, age: years, role = 'member', address: { city } } = self.user;
  pln(`${name}, ${years}, ${role}, ${city}`);

  const [first, _, third] = ['a', 'b', 'c'];
  pln(first + third);

  for (const (key, value) in map(('calls', 100), ('seats', 5))) {
      pln(`${key} = ${value}`);
  }
}
Output

See Variables & References.

Workflows From Types​

obj.run() runs an object's #[run] steps, and in 0.10.6 it works the way you'd expect with types. Steps run in the order they're written. A type's steps run on every instance, each step once. An instance can replace a step by giving one the same name, remove one with null, or add its own, and a number moves any step before or after the rest:

run.stofPlayground ↗
#[type]
Onboarding: {
  str email: ''
  list log: []

  #[run]
  fn check() { self.log.push_back('checked ' + self.email); }

  #[run]
  fn welcome() { self.log.push_back('welcome email'); }
}

Onboarding team: {
  email: 'team@example.com'

  #[run]
  fn welcome() { self.log.push_back('team welcome email'); }

  #[run(-1)]
  fn verify() { self.log.push_back('verified'); }
}

#[main]
fn main() {
  self.team.run();
  pln(self.team.log);
}
Output

run(steps = ['check']) runs just the steps you name. See Object Run.

Smaller Additions​

  • Lib::func() always calls the library, even when an object or variable has the same name: Num::round(x, 2). Lib?::func() gives null when the library isn't there, which is handy for optional host libraries.
  • Null-safe calls anywhere in a path: self.plugins?.on_save().
  • Qualified type names: <Geometry.Point> finds the type Point under Geometry, from anywhere in the document.
  • Assigning to computed targets: self.customer(id).plan = 'pro' and list[0].name = 'a'.
  • Trailing commas in calls and parameter lists (0.10.3).
  • New asserts: assert_null and assert_not_null, plus inf() and nan().
  • JavaScript: plain objects passed to call arrive as maps (like Python dicts), library functions can declare parameter names for named arguments, and a rejected promise in an async library function now throws inside Stof, so try/catch works.
  • Python: Doc.get, set, call, and binary_export have sensible defaults.

Fixed in 0.10.6​

  • Left to right. Operators and template strings evaluated their right side first, so side effects ran out of order. Everything now runs left to right.
  • Arrow functions keep their variables. An arrow passed to another function, or stored and called later, now sees the variables around where it was made, and can update them.
  • Calls that return nothing give null where a value is expected (const x = self.log();, f(self.log())), instead of an internal error.
  • Parsing into an object again merges into its nested objects instead of replacing them, so loading the same config twice, or a partial update, does what you'd expect.
  • pln in the browser writes to the console.

Faster​

  • Parsing: a 5,000-line document (the Limitr pricing spec) parses 60 times faster, from 2.9s to 0.05s. Parsing is a runtime operation in Stof, so this matters every time logic is loaded or swapped.
  • Calls: function calls are about twice as fast (200,000 calls went from 2.9s to 1.5s), and loops are faster too (a million iterations went from 4.1s to 3.0s).
  • Large documents (0.10.4): drop, funcs(attributes = ..), and method calls on objects with many fields no longer slow down as a document grows.

Safer, Too​

A fuzzing sweep over the test suite (40,000 parsed documents, 20,000 corrupted binary documents) found parser hangs, stack overflows, panics, and an out-of-memory crash in binary decoding. All are fixed: no known input crashes or hangs the runtime. All 13 cargo audit advisories in dependencies are fixed too.

Tools​

  • A language server. stof lsp, in stof-cli 0.13, gives editors errors and warnings as you type, an outline, go to definition, hover with doc comments and library docs, completion, find references, and rename. Files in a package are checked together.
  • VS Code extension 0.3 uses it, and its highlighting is updated for the new syntax. Get it on the Marketplace.
  • The CLI exits with an error code when a run, test, or parse fails, so it fits in CI, and stof test --leaks checks for leftover objects.
  • A new agent skill. The Stof skill teaches AI coding agents to write correct Stof. It's rewritten from 1,900 lines to about 400, every example in it is tested. In our evaluation, agents using it solved all three tasks on the first try, with every test passing.
  • New docs. This site has been rewritten alongside the release, with a cookbook of real uses and a playground with a scratch pad, a test runner, and shareable links.

Breaking Changes​

ChangeBeforeNowWhat to do
SandboxRust and Python documents could use files, environment variables, and the network by defaultNothing outside the document unless the host opts inCall allow_system() or allow_http() (Rust, Python), or allowHttp() (JS)
Pathsa.b found any object named aa must be a rootUse self.a.b, super.a.b, or root.a.b
Function scopeFunctions could read their caller's variablesOnly their own parameters and variablesPass values as arguments
Math with null3 * null == 3An errorUse ??, and start totals at 0
Number methodsx.round(2) changed xReturns a new valuex = x.round(2)
Template stringsCould add numbers; null printed as emptyAlways a string; null prints nullFormat numbers explicitly
ImportsRelative to the working directoryRelative to the importing fileFix paths that assumed the working directory
returnreturn x without ; mis-parsedreturn x; needs its semicolonAdd the ;
IntegersOverflow wrapped; / 0 crashedOverflow becomes a float; / 0 is inf
typeof (0.10.2)Took the rest of the expressionBinds to the next valueUse typeof (a + b) for an expression

The full changelog has every change, including the Rust API details.

Tell Us​

If something breaks when you upgrade, or you build something with Stof, tell us on Discord or open an issue on GitHub.