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 StoreErrorwith { 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.
Capability row on a declaration
Section titled “Capability row on a declaration”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: StoreThe 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.
Several capabilities
Section titled “Several capabilities”fn create_session(id: Id) -> Session with { store: Store, clock: Clock } raises StoreError{ let user = store.load(id)!; Session::new(user, clock.now())}Open capability row
Section titled “Open capability row”{ 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()}Handler values
Section titled “Handler values”let fixed_clock = handler for Clock { now: fn () => fixed_instant,};A handler’s type is the effect it implements.
Installing the real thing
Section titled “Installing the real thing”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.
Postfix with
Section titled “Postfix with”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.
with block
Section titled “with block”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 BlockHandlers 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 capability 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.
Sequential bindings
Section titled “Sequential bindings”Bindings inside a capability 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.
Named context declaration
Section titled “Named context declaration”pub context Production { config: env_config(), scope: Scope::root(), db: postgres_db()!, store: sql_store(),}General form:
pub? context Name { label: Expr, ...}Use a named context
Section titled “Use a named context”Postfix:
load_user(id)! with ProductionBlock:
with Production { run_server()!}Override named-context entries
Section titled “Override named-context entries”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 capability 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 capability as an ordinary parameter
Section titled “A capability as an ordinary parameter”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.
Capability rows on function values
Section titled “Capability rows on function values”Request -> Response with { db: Db, clock: Clock }Generic row:
A -> B with 'efCapability rows are part of function types, so higher-order functions can preserve requirements without a runtime service locator.
Capabilities do not imply failure
Section titled “Capabilities do not imply failure”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.
Manifest permissions
Section titled “Manifest permissions”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 dialed — 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 authorize 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.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource