Skip to main content

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 with stof_units: 'MB' keeps its meters in megabytes, so 10GB is 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:

FunctionWhereSignature
Cost functionOn a creditfn overhead(units: float, context?: obj) -> float
Feature ruleOn an entitlementfn enabled(customer: Customer, entitlement: str, context?: obj) -> bool
NotificationUnder notificationsfn matches(type: str, event: obj) -> bool, fn fire(event: obj)
  • Parameters and return values are typed. context? can be left out (it's null).
  • self is the object the function is defined on: the credit, the entitlement, the notification.
  • The last expression, with no semicolon, is the return value. return works too.
  • const and let declare variables. if, for, while, and switch work 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?.model is null when context is 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) or drop(obj), not overwriting a field.
  • Check it: stof test --leaks fails 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​

BeforeNow
?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 nullAn error: default with ??.

The changelog lists every change.