09 — Capabilities

Previous: Effects (the heart of vibe)

日本語版: 09_capabilities.vibe.md

The last chapter's effects were ones you implement, with a handler. Reading a file is not like that: the host already knows how, and the question is whether your program is allowed to.

That is a capability. It rides the same row, but what the row records is permission, and the permission is decided when the program is built — not checked again at every call. The call itself stays an ordinary call.

This is Deno's permission flags composed with Koka's effect system.

Permission is part of the signature

fn greet(name: String) -> Unit with Console {
  println("hi \{name}")
}

fn main allows Console {
  greet("vibe")
}
hi vibe

greet writes to the terminal, so it says with Console: it requires the capability from whoever calls it. main calls greet, and nothing calls main — it is where the program starts, so it grants the capability: allows Console. The capability does not appear at main by magic — it is inferred from the calls and then checked against what you wrote. A function whose signature omits it will not compile.

Console is the terminal capability. Write that name. Stdin, Stdout and Stderr are older labels the compiler still accepts; each covers only its own operations, and none of them covers Console.

with requires, allows grants

Two keywords, one row. A called function has a caller, and the row is what it asks that caller for — with. An entry point has no caller: the run brings the authority in, and the row is what the run grants — allows. The entry points are fn main, fn _start, and the test, bench and example blocks of chapter 12.

So allows is written on an entry point and nowhere else. On a called function the compiler names the edit:

// skip: `allows` on a called function
fn greet(name: String) -> Unit allows Console {
  println("hi \{name}")
}
`allows` grants authority and is written on an entry point only (`fn main`,
`fn _start`, `test`, `example`, `bench`); `greet` requires its effects from
the caller -- write `with` here and grant the effect at the entry

The other direction is refused too. main has no caller to require anything from, so with on its head is a parse error, and the message carries the row it read so the edit is complete:

// skip: `with` on an entry point
fn main with Console {
  println("hi")
}
`fn main` is an entry point and grants its row, so the keyword is `allows`:
write `fn main allows Console`

Authority stays per-operation. allows Console::write_stream does not grant Console::read_stream — a program that may print does not thereby acquire the right to read the terminal.

Naming a bundle of capabilities

A program that grants the same set at several entry points can name it once. An effectset is a set of row items, and an entry grants it like any other item:

effectset AppCaps = { Console, Fs::read_file }

fn main allows AppCaps {
  println("bundled")
}
bundled

The set is expanded before anything is checked, so allows AppCaps is exactly allows Console + Fs::read_file — no more, no less.

Optional capability: perform? (experimental)

A ? on an allows item marks it optional — the program can run whether or not the host granted it. The matching perform? Fs::read_file("p") gives back an Attempt: Granted, NotGranted, or Errored.

This part of the language is experimental. Which arm a program takes depends on how it was built, because nothing yet records a grant once and carries it from build to run:

the capability is granted, so the operation runs and answers Granted, or Errored when it fails (a file that does not exist). --deny-fs makes it NotGranted.

to consult, so they fix every optional capability to NotGranted at compile time. The operation and its arguments are not evaluated, and the host import is not in the artifact.

with full authority, so NotGranted could never happen there.

The outputs shown here are the NotGranted answers. Under vibe run with no flag, there is no config.json or cache.json to read, so the same programs take the Errored arm (errored, and cache failed).

fn main allows Console + Fs::read_file? {
  match perform? Fs::read_file("config.json") {
    NotGranted => println("not granted"),
    Errored(_) => println("errored"),
    Granted(_) => println("granted")
  }
}
not granted

An optional grant never stands in for a required call: Fs::read_file("p") under allows Fs::read_file? is rejected, and the message offers the two edits — call it with perform?, or grant it without the ?.

A called function can require the optional grade the same way, and then perform? lives where the fallback logic lives. A caller satisfies with Fs::read_file? with either grade of the grant — allows Fs::read_file or allows Fs::read_file? — because a required grant is the stronger one:

import @vibe/core { Attempt }

fn cached() -> Attempt[String, String] with Fs::read_file? {
  perform? Fs::read_file("cache.json")
}

fn main allows Console + Fs::read_file? {
  match cached() {
    NotGranted => println("no cache"),
    Errored(_) => println("cache failed"),
    Granted(_) => println("cache hit")
  }
}
no cache

The ? marks a host capability and nothing else: with Ask::Get? on an effect you declared, or allows Exception?, is refused — nobody outside the program could withhold those, so the grade would be a claim about nothing.

Because the answer still depends on the build, do not rely on perform? for behaviour that must be the same everywhere the program runs.

Telling the two kinds apart

Both ride the row, and the spelling says which you are looking at:

| | example | you write | who implements it | |---|---|---|---| | algebraic effect | Ask::Value | perform + a handle | you | | capability | Fs::read_file | an ordinary call | the host |

Effect::CamelCase is an operation you perform; Effect::snake_case is a function you call. That is the rule, and it is why Fs::read_file(p) reads like any other call even though it needs authority.

An entry grants only what the run can bring in: the host capabilities, Exception (a failure the runtime boundary reports), and Async. An effect you declared yourself has no host behind it, so it cannot be granted — it is handled, with handle, before it reaches the entry.

What denial actually does

vibe run --allow-* and --deny-* decide grants before that command compiles and runs the program. A denied optional capability is const-folded and removed from the artifact — the code that needed it is not in the wasm. vibe build accepts neither flag and compiles optional capabilities as NotGranted. A program that never gets Http does not ship networking code, and does not demand a runtime that can do networking (feature levels).

At startup, a required capability the host did not grant aborts before main runs and names the flag that would have granted it.

Next: Option and the railway.