Skip to content
You are reading v0.2, which is not the current release. Go to v0.4.

Capabilities

A function that needs to touch the outside world says so in its signature, and the caller has to hand it the means:

fn load_user(id: Id) -> User with { store: Store } raises StoreError

with { store: Store } is the requirement. Callers supply it with a with block. Nothing reaches a database, a clock or the network without a caller passing it in.

fn load_user(id: Id) -> User
with { store: Store }
raises StoreError
{
store.load(id)!
}

The row entry has a label and an effect type:

store: Store

The label store is in scope in the function body.

The call mirrors the declaration. The function writes with { store: Store } to say what it needs; the caller writes with { store: memory_store } to supply it. Read the first as this function requires a capability named store implementing the Store effect.

fn create_session(id: Id) -> Session
with { store: Store, clock: Clock }
raises StoreError
{
let user = store.load(id)!;
Session::new(user, clock.now())
}
{ clock: Clock | 'ef }

The named capability is required and 'ef represents any additional row entries.

A function can preserve the open row:

fn run<A, 'ef>(body: () -> A with 'ef) -> A
with 'ef
{
body()
}
let fixed_clock = handler for Clock {
now: fn () => fixed_instant,
};

A handler’s type is the effect it implements.

The handlers above are invented, because a page about syntax should not depend on which effects std happens to ship. Real programs install the ones it does, and each capability’s own module provides them:

import std::clock::{Clock};
import std::env::{Env};
import std::fs::{FsRead, FsWrite};
import std::log::{Severity, Log};
pub fn main() -> Int {
with {
env: Env::real(),
reads: FsRead::real(),
writes: FsWrite::real(),
clock: Clock::real(),
log: Log::json(Severity::Info),
} {
work()
}
}

The label is chosen by the function you are calling, not by you. A row entry named fs: will not satisfy a function that declared reads: FsRead; the error says so — needs `reads: FsRead`, which this function does not require — but it is worth knowing before you meet it, because the labels are effectively part of std’s public API:

Capability Label From
Env env std::env
FsRead reads std::fs
FsWrite writes std::fs
Clock clock std::clock
Random random std::random
Log log std::log
Tracer tracer std::trace
Db db std::db
HttpClient client std::net::http
Process process std::process
Nursery nursery std::core
Scope scope std::core

Every real() reaches the actual machine. A test installs a handler of its own instead — see Testing capabilities, which is what the seam is for.

Install handlers for one expression:

let user = load_user(id)! with {
store: memory_store,
};

General form:

Expr with { label: HandlerExpr, ... }

The installation is postfix, so it applies to the expression immediately before it.

Install handlers for a lexical region:

with {
store: memory_store,
clock: fixed_clock,
} {
let user = load_user(id)!;
create_session(user)
}

General form:

with ContextRow Block

Handlers lexically enclose the operations they serve. That is a real consequence of direct style rather than a syntactic detail: the operation runs when the call is evaluated, inside the block, rather than being collected into a description that something runs later.

A closure written inside the block is the one case worth being exact about. It captures the capability, because a context row is a block of bindings and a capability is an ordinary binding, so the closure keeps the handler it was written with even when it is called somewhere else – including after the block has ended. That is what makes a callback that logs cost nothing in the type of whatever takes it. The handler stays alive as long as the closure does.

Bindings inside a context row are sequential. A later expression may use handlers introduced above it:

with {
config: env_config(),
scope: Scope::root(),
db: postgres_db()!,
store: sql_store(),
} {
run_server()!
}

This allows service construction to remain flat rather than nesting one installation block per dependency.

pub context Production {
config: env_config(),
scope: Scope::root(),
db: postgres_db()!,
store: sql_store(),
}

General form:

pub? context Name {
label: Expr,
...
}

Postfix:

load_user(id)! with Production

Block:

with Production {
run_server()!
}
load_user(id)! with Production {
store: test_store,
}

or around a block:

with Production {
store: test_store,
} {
run_test_case()!
}

Entries written at the use site replace or extend the corresponding context row for that installation. This is what makes a named production context usable from a test: install Production and override the one capability the test wants to control.

A with row is not the only way in. An effect value is a value, so a function can take one as a plain parameter and call its operations with the same method syntax:

import std::clock::{Clock};
import std::core::{print};
/// The capability arrives as an argument.
fn stamp(clock: Clock, message: String) -> String {
"${clock.unix_millis()}: ${message}"
}
/// The same thing said with a row.
fn stamped(message: String) -> String with { clock: Clock } {
"${clock.unix_millis()}: ${message}"
}
pub fn main() {
let clock = Clock::real();
print(stamp(clock, "started"));
print(stamped("started") with { clock: clock });
}

The two forms differ in who chooses. A row is requested: the caller decides what to install, and every intermediate frame passes it along without naming it. A parameter is given: the caller has a particular handler in hand and hands that one over. Prefer the row — it is what makes an intermediate function say nothing about a capability it only passes through.

Reach for the parameter where a row cannot go. The clearest case is a type that has no capability row to put one in. SharedFn<A, B, 'er>, which Router::get takes, is (A) -> B raises 'er and nothing else — there is no with in it, so a handler needing a database or an HTTP client looks impossible to write. It is not: the handler takes the capability as a parameter, and the closure that is certified closes over it.

import std::core::{Result, SharedFn};
import std::net::http::{Answer, Call, HttpClient, Request, Response, Router};
/// The capability arrives as a parameter, because `SharedFn` has no row.
fn items(client: HttpClient, _request: Request) -> Response {
match client.send(Call::get("http://inventory/items")) {
Result::Ok(answer) => Response::text(answer.status, answer.body),
Result::Err(_why) => Response::text(502, "inventory unreachable"),
}
}
fn routes<'er>(client: HttpClient) -> Router<'er> {
Router::new()
|> Router::get("/items", SharedFn::of(fn request => items(client, request)))
}

A handler is written the same way whether the capability came from a row or an argument, so a function can be moved from one to the other without its body changing.

Request -> Response with { db: Db, clock: Clock }

Generic row:

A -> B with 'ef

Capability rows are part of function types, so higher-order functions can preserve requirements without a runtime service locator.

fn choose_bucket() -> Int
with { random: Random }
{
random.in_range(0, 10)
}

A capability may be required by an operation that does not raise a recoverable failure. Conversely, a pure computation may use raises without any with requirement.

A capability row says a function reaches outside the program. [permissions] in khora.toml says how far it may reach, and the standard library’s real handlers enforce it:

[permissions]
network = ["api.example.com:443", "*.internal"]
env = ["PORT", "DATABASE_*"]
[permissions.fs]
read = ["./data", "./data/**"]
write = ["./data/out.txt"]

Both read entries above are needed, and the reason is the one surprise in the glob dialect below: ./data/** grants what is inside data and not data itself, so with only that line a program can read every file in the directory and cannot list it. read_dir("data") and is_dir("data") both raise Denied rather than answering false, for the reason given under the probes below.

The grants are compiled into the binary rather than read at run time. A file the program consults for its own permissions is a file an attacker edits.

A missing table grants everything, and each category is independent. Naming network says nothing about env. Tightening is opt-in.

network is a list of hosts the program may connect out to, and nothing else. It is read in one place — HttpClient::send, once the URL is parsed and before anything is dialled — and it decides whether that host may be reached. Binding a port is the other direction and is not covered: Router::listen, Router::listen_quietly and Router::listen_tls never consult it, so a program with default = "deny" and no hosts granted still serves on any port it asks for. Write network = ["127.0.0.1:8787"] expecting it to authorise a listener and it will do nothing, in either direction. Known limitations has the reasoning.

A denial is its own error case, separate from the ordinary failure, because the two send a reader to different files:

Reaching for Denied as Where the fix is
an environment variable EnvError::Denied(name) [permissions] env
a host over HTTP CallError::Denied(host) [permissions] network
a path on disk IoError::Denied(path) [permissions.fs]

Including the two probes. FsRead::exists and FsRead::is_dir answer a Bool and raise IoError as well, so a path the manifest denies is Denied rather than false. They answered a plain Bool until recently, which made three situations one word — not there, there but unreadable, and there and readable and simply not granted — and the third is the one somebody debugs for an hour, because nothing in a false points at a manifest. The remaining false means one thing: the operating system would not open it.

Unreachable is DNS or a firewall; Denied is a line you can copy out of the message into the manifest. Env::variable and std::env::variable_or therefore raise EnvError, so both need a ! at the call site:

let port = variable_or("PORT", "8080")!;

Globbing differs by category, and each one is the reading that costs the least surprise. For a path, * stops at a separator and ** crosses one — and, as in .gitignore, neither covers the directory being described, which is why the [permissions.fs] example above names ./data as well as ./data/**. For a name — a variable, a command — there are no segments, so * spans everything. For a host, * spans dots, so *.internal covers db.eu.internal, and a grant with no port covers every port.

See Effects and rows for effect declarations and Failures for typed failure.

Khora 0.2.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource