Capabilities

Most languages let any function anywhere read the clock, touch the disk or open a socket. Lumina doesn't. Effects are passed in as parameters. A function that takes no capabilities is pure: it computes a result from its arguments and does nothing else. A function that needs the outside world says which part of it in its signature.

The entry point receives the world

function main(console: Console, fs: FileSystem): Integer {
    fs.write("/tmp/lumina_doc_probe.txt", "a\nb\nc")
    match countLines("/tmp/lumina_doc_probe.txt", fs) {
        Ok(n) => console.println(`{n} lines`),
        Err(e) => console.println(`failed: {e}`)
    }
    return 0
}

function countLines(path: String, fs: FileSystem): Integer!Error {
    text: String = fs.read(path)?
    return Ok(text.split("\n").length())
}
3 lines

main asks for Console and FileSystem, and the runtime supplies the real implementations. From there they flow down explicitly. countLines can read files, and its signature says so. It cannot print or open a socket, because it was not given those capabilities.

This is dependency injection built into the language, with no container, no decorators and no reflection.

Defining your own capability

A capability is an interface marked @Capability:

@Capability interface Clock {
    function now(self): Integer
}

struct FixedClock implements Clock {
    timestamp: Integer

    public function now(self): Integer = self.timestamp
}

Now separate the decision from the effect:

// Pure: no capability, so no I/O. Easy to test.
function isExpired(createdAt: Integer, ttl: Integer, now: Integer): Boolean = now - createdAt > ttl

// Effectful: the Clock it reads is visible in the signature.
function sessionExpired(createdAt: Integer, clock: Clock): Boolean = isExpired(createdAt, 3600, clock.now())

Testing with fakes, not mocks

Because the clock is a parameter, the test simply passes a different one. You need no monkey-patching, no global time freezing and no mocking framework:

@Test
function expiresAfterOneHour(): Void {
    assert(sessionExpired(0, FixedClock { timestamp: 3601 }))
    assert(!sessionExpired(0, FixedClock { timestamp: 60 }))
}
$ lumina test session.lumina
Results: 1 passed, 0 failed, 0 skipped (1 total)
All tests passed.

Pure @Test functions like this one are also run at compile time when the file is compiled. A failing pure test fails the build before the program starts:

[compile-time test] FAIL doublesCorrectly
SUMMARY: 0 passed, 1 failed
error[E4016]: ... compile-time test failed: assertion failed [1 of 1 failed]

The same pattern works for FileSystem (an in-memory fake), Network, Process, Environment and random numbers. The spec suite in tests/spec/47_capability_interfaces/ and tests/spec/27_provide/ has a working example for each.

@Provide: fill capabilities from scope

Threading a capability through many layers can get noisy. @Provide binds an implementation for a block, and calls inside that block that omit the capability argument receive it automatically:

@Capability interface Greeter {
    function greet(self, name: String): String
}

struct FormalGreeter implements Greeter {
    public function greet(self, name: String): String = `Dear {name}`
}

function welcome(name: String, g: Greeter): String = g.greet(name)

function main(console: Console): Integer {
    @Provide(Greeter: FormalGreeter())
    {
        console.println(welcome("Alice"))     // g is supplied by @Provide
    }
    return 0
}
Dear Alice

The parameter is still in welcome's signature, so the dependency stays visible. Only the call site gets shorter. Bindings are resolved by the dynamic scope, are inherited by tasks spawned inside the block, and can be shadowed by a nested @Provide. Passing the argument explicitly always wins.

Effect handlers

For effects that are better modeled as "ask the context", Lumina also has algebraic effects. A function declares the effects it performs with / {Effect}, and the caller installs a handler:

import from "lumina.meta"

effect Config {
    lookup(): Integer
}

function retries(): Integer / {Config} {
    return perform Config.lookup() + 1
}

function main(console: Console): Integer {
    result = handle {
        retries()
    } with Config {
        lookup() => resume(2)
    }
    console.println(`retries = {result}`)    // retries = 3
    return 0
}

An effect that escapes main without a handler is a compile error (E9003). The stdlib's LLM integration is built on this mechanism; see Interop & Stdlib.

How strict is "pure"?

Capabilities also bound code an agent writes at run time; see Agents inside your program.

Language_Capabilities.md and Language_Effects.md have the full design.

Next: Async