Write policies in Stof
A Limitr policy is a Stof document. Most of a policy is data, and JSON, YAML, or TOML work for that. Stof adds what a pricing policy sometimes needs: comments, units, and functions that travel with the policy and run in a sandbox.
This page covers the Stof used in policies. The language itself is documented at stof.dev.
#[type]
Pricing: {
rates: {
large: { input: 3e-6, output: 15e-6 }
small: { input: 1e-6, output: 5e-6 }
}
#[static]
/// Provider cost of one call, shared by every AI credit.
fn ai_cost(units: float, context?: obj) -> float {
const rate = <Pricing>.rates.get(context?.model ?? '');
if (rate == null) return units * 0.000005;
const input = context.input ?? 0;
const output = context.output ?? Num::max(units - input, 0);
input * rate.input + output * rate.output
}
#[static]
/// Staff first, then a share of everyone else.
fn rollout(customer: Customer, entitlement: str, percent: int) -> bool {
(customer.metadata?.staff ?? false) || customer.bucket(entitlement) < percent
}
}
policy: {
credits: {
storage: { label: 'Storage', stof_units: 'MB' }
ai_token: { price: { amount: 0.00002 }, overhead: <Pricing>.ai_cost }
}
plans: {
pro: {
default: true
entitlements: {
storage: { limit: { credit: 'storage', value: 10GB } }
chat: {
limit: {
credit: 'ai_token', mode: 'soft', value: 2_000_000
resets: true, reset_inc: 30days
}
}
voice: {
fn enabled(customer: Customer, entitlement: str, context?: obj) -> bool {
<Pricing>.rollout(customer, entitlement, 20)
}
}
}
}
}
notifications: {
big_call: {
fn matches(type: str, event: obj) -> bool {
type == 'meter-changed' && (event.meter.overhead_diff ?? 0) > 0.01
}
fn fire(event: obj) {
using summary = new {
customer: event.customer.id
cost: (event.meter.overhead_diff ?? 0).round(4)
};
App?::big_call(stringify('json', summary));
}
}
}
}
Data
- JSON is valid Stof. Keys don't need quotes, commas between fields are optional, and strings can use single or double quotes.
- Comments:
//and/* */.///before a function documents it. - Numbers can use underscores (
2_000_000) and exponents (3e-6). - Units are part of numbers: sizes (
10GB,250MB,1GiB), time (1s,10min,1day,30days). A credit withstof_units: 'MB'keeps its meters in megabytes, so10GBis 10,000 and'2.5GB'from your app counts 2,500.
Functions
Three kinds of function show up in policies, each called by the engine:
| Function | Where | Signature |
|---|---|---|
| Cost function | On a credit | fn overhead(units: float, context?: obj) -> float |
| Feature rule | On an entitlement | fn enabled(customer: Customer, entitlement: str, context?: obj) -> bool |
| Notification | Under notifications | fn matches(type: str, event: obj) -> bool, fn fire(event: obj) |
- Parameters and return values are typed.
context?can be left out (it's null). selfis the object the function is defined on: the credit, the entitlement, the notification.- The last expression, with no semicolon, is the return value.
returnworks too. constandletdeclare variables.if,for,while, andswitchwork as you'd expect.
Shared functions in a type
A #[type] holds functions (and data) that many parts of a policy share. Mark functions #[static] and call them as
<Pricing>.rollout(...). A field can point straight at one: overhead: <Pricing>.ai_cost or
enabled: <Rollouts>.beta. A shared function isn't defined on the credit or entitlement, so self inside it isn't
either: keep the data it reads on the type (<Pricing>.rates), or take it as a parameter.
Library calls
Stof's standard libraries are called as Lib::function(...), Ex. Num::max(a, b) and Time::now().
Methods are called on values: rate.round(4), self.rates.get(name), list.push_back(x).
Your app's functions are a library too, registered by your app (limitr.doc.lib('App', 'big_call', fn)).
App?::big_call(...) calls one if it's registered and does nothing if not, so a policy works with or without it. See
Call your app from a policy.
Missing values
?.reads a field that might be missing:context?.modelis null whencontextis null.??gives a default:context?.model ?? ''.- Comparing with null is false (
null < 1000), but arithmetic with null is an error. Default every field you do math with:(event.meter.limit ?? 0) * 0.8. - An engine-called function that fails is safe (a cost function falls back to
overhead_cost, a rule counts as off), and the error is logged.
Memory
The engine document can run for months, and Stof has no garbage collector: every object created stays until it's dropped. The engine creates nothing per call, and functions you add must keep it that way.
- Temporary objects get
using:using summary = new { ... };drops it when the function returns, on every path. - Don't store per call. Don't add fields to the policy, customers, or events from a cost function, rule, or notification.
- Event objects are dropped after delivery. Copy plain values out if you need them later.
- Removing objects:
obj.remove(name)ordrop(obj), not overwriting a field. - Check it:
stof test --leaksfails tests that leave objects behind (Test a policy).
Keep functions small and pure
Cost functions and rules run on every call, including check. Read the policy and the arguments, compute, and
return. Don't change the policy, call your app, or use randomness or the clock in a rule (a feature would flip
between calls; use customer.bucket(key) for rollouts). Notifications are the place for side effects.
Policies from before 0.7
| Before | Now |
|---|---|
?App.notify(x) | App?::notify(x) |
Time.now(), Num.max(a, b) | Time::now(), Num::max(a, b) |
<Limitr>.api.allow(...) | root.policy.allow(...) (the policy is always at root.policy) |
new { ... } then drop(x) | using x = new { ... }; |
null * 2 was null | An error: default with ??. |
The changelog lists every change.