Tools and Capabilities¶
A tool is a function with a boundary: it says up front what it is allowed to do to
the world outside the program, and the compiler holds it to that.
tool ingest(src: String, dst: String): Int
requires { read(src), write(dst) }
{
val data = Files::readText(src)
Files::writeText(dst, data)
return 0
}
The requires clause lists capabilities: effects the body may perform, each
optionally tied to the parameter it acts through. If the body — or anything it calls,
however indirectly — performs an effect the clause does not declare, the program does
not compile.
The deal: inference everywhere, declaration at the boundary¶
Ordinary functions carry no effect annotations, ever. The compiler infers what each one
can do by looking at its body and joining across calls (the effects
reference describes the vocabulary and the analysis). You
declare effects in exactly one place — a tool boundary — and the check compares the
declaration against the inference:
def helper(msg: String): void {
IO::println(msg) // inferred: console — no annotation needed
}
tool speak(msg: String): Int
requires { console } // declared once, at the boundary
{
helper(msg) // covered: helper's inferred console is inside the clause
return 0
}
There is no effect polymorphism, no effect variables in ordinary signatures, no handlers. That restraint is deliberate: the point is a checkable promise at the edge of a program, not a whole-language effect system.
What a violation looks like¶
A tool that declares only read and then writes fails to compile, and the diagnostic
names the effect, the callee, and the exact call that introduced it:
ingest.on:5:8: [E0077] tool `sneaky` performs `write` here (calling
onion.Files::writeText) but does not declare it. Add `write` to its
`requires { ... }` clause.
5 | Files::writeText(dst, data)
| ~~
The check is honest in the other direction too. A capability the body cannot exercise
is an error, not decoration — a tool that claims net and never touches the network is
overclaiming, and gets E0078. And a capability that is not in the vocabulary, or that
names a parameter the tool does not have, is E0079.
unknown must be admitted, not assumed away¶
A Java method the effect table cannot vouch for has unknown effects — no table will
ever cover the JDK plus every jar on a classpath. Treating unknown as forbidden would
kill interop; treating it as harmless would make the guarantee a lie. So unknown
propagates like any other effect and must be admitted explicitly at the boundary:
import { java.util.Random; }
tool roll(): Int
requires { unknown } // says out loud: this body calls code I cannot vouch for
{
return new Random().nextInt()
}
Without the clause, the call site gets E0077 naming unknown. With it, the tool's
contract is truthful: "this does something the analysis cannot bound."
What a tool costs at runtime: nothing¶
Effects are checked during type checking and erased there. A tool compiles to
exactly the bytecode of the equivalent function — the compiler's test suite pins this
by disassembling both and comparing instruction for instruction. The boundary is a
compile-time promise, not a runtime sandbox: it costs nothing to call a tool, and it
stops nothing at runtime. What it guarantees is narrower and more useful — a tool that
compiles cannot quietly do more than its declaration says.
Two details worth knowing¶
A closure's effects are charged to the function that creates it, not the one that
eventually invokes it. If a tool builds a lambda that prints, the tool needs console
even if the lambda is only invoked elsewhere — the creation site is the one the checker
can always see.
And tool / requires are soft keywords: only tool name( opens a declaration and
only requires { opens the clause, so both remain usable as ordinary identifiers in
existing code.
From declaration to command line¶
A script whose top level declares tools — and has neither its own main nor top-level
statements — is a command-line program. The compiler derives everything from the
declarations:
$ onion ingest.on --contract
[{"tool":"ingest",
"params":[{"name":"src","type":"String","role":"positional"},
{"name":"dst","type":"String","role":"positional"},
{"name":"count","type":"Int","role":"flag","default":"3"},
{"name":"loud","type":"Boolean","role":"switch","default":"false"}],
"returns":"Int",
"capabilities":["read(src)","write(dst)","console"]}]
$ onion ingest.on --help
usage: ingest.on <src> <dst> [--count <Int>] [--loud]
<src> String
<dst> String
--count <int> Int (default: 3)
--loud Boolean (default: false)
requires: read(src), write(dst), console
The contract is the single source. --contract prints it verbatim for an agent to
read; --help, flag parsing, typed conversion and error messages are all derived from
it at runtime, and the typed call into your tool is derived from the same declaration
at compile time. Required parameters are positionals; defaulted parameters become
--name flags (--count 5 or --count=5); Boolean defaults become switches. A
camelCase parameter answers to its kebab-case flag as well as to its own name —
maxRows is --max-rows or --maxRows — and --help shows the kebab-case one; two
parameters that would share a flag (parseURL and parseUrl) are a compile error (E0093). A
default that is absent on the command line is evaluated as the original expression, in
the language — it is never round-tripped through a string.
The parsing and typed conversion behind --help/flag handling is generated code calling
onion.Cli, a lower-level runtime module also available for direct use — see
Cli Module in the stdlib reference.
A default that isn't a literal (= retries() + 1, say) has no value the contract can
quote, so its entry carries "defaultComputed":true instead of a "default" key.
--help describes it as (default: computed at call time), and --plan reports an
unset operand tied to such a parameter as a computed default rather than fabricating or
omitting a value — the same honesty rule --plan applies everywhere else.
A bare -- ends the options: everything after it is a value, however it is spelled, so
a tool can be handed an argument that starts with --. The three mode flags
(--help, --contract, --plan) are mutually exclusive and passing two is an error
rather than a silent choice between them. Tool names must be distinct (E0082), since
the name is the only selector a command line has.
Failures are exit codes, not System.exit: a missing positional, a value that does not
parse as its declared type, or an unknown option each print a message naming the
argument and the expected type, then return 1 from main. When a script declares
several tools, the first argument selects one by name, subcommand-style, and the
contract carries one entry per tool.
Every parameter of every tool must be one of the CLI-convertible types — String,
Int, Long, Double, Float, Boolean, Short, Byte — since there is no general
way to parse an arbitrary type from a command-line string. A tool with a parameter of
any other type (a record, for instance) fails to compile, naming the offending tool and
parameter, rather than silently compiling to a script that never calls it.
The capability line in --help and the capabilities field in the contract are not
documentation — they are the checked declaration from the section above. What the
contract says the tool may do is what the compiler proved it cannot exceed.
--plan: what a run would do, without doing it¶
The payoff of checked capabilities. Because the compiler knows what the body can do, the CLI can report what a particular invocation would do — the declared effect set instantiated with the actual argument values — and exit without performing any of it:
$ onion ingest.on access.log /backup/access.log --plan
plan: `ingest` would
read src = access.log
write dst = /backup/access.log
console
(nothing was executed)
A capability binds an effect to a parameter, so write derived from dst = … says the
write goes through dst — not that the run touches exactly that path, since a body is
free to build dst + ".1" out of it. Arguments are parsed exactly as a real run would
parse them — a bad value fails the
plan the same way it would fail the run — and an operand left to its default shows the
default from the contract. The honesty rules are strict in both directions. An ambient
effect (console, clock, env, rand) is named bare. An operand the analysis
cannot tie to a parameter is reported as (operand not statically known), never
guessed. And a body carrying unknown says so out loud:
A plan that quietly omitted what it could not characterize would be worse than no plan. This is what makes the dry run trustworthy rather than decorative: a CLI derived from a signature is commodity; a plan derived from the checked effect set requires knowing what the body does.
Operands the source spells out¶
A bare capability (net, exec, env, read, write) is not tied to a parameter,
but its operand is often sitting in the source as a literal — the API host, the command
a tool shells out to, the environment variable it reads. --plan prints those:
$ onion digest.on out/digest.md gh --plan
plan: `digest` would
write derived from out = out/digest.md
exec gh … list --repo onion-lang/onion --state all --search … --json number,…
net $SLACK_WEBHOOK_URL
net api.github.com
env SLACK_WEBHOOK_URL
env GITHUB_TOKEN
clock
console
(nothing was executed; operands are the arguments the effects are
derived from, not necessarily the exact paths or hosts touched)
The compiler reads, from every call the tool can reach (its body, closures it creates, and the program's own methods it calls, transitively):
| Effect | Operand shown | Read from |
|---|---|---|
net |
the host (and port) | the URL of Http::get/post/put/delete/postJson/getResponse/postResponse/request (through the builder steps to send()), an http"…" resource, Net::connect |
exec |
the command, with … for each non-literal argument |
the command words of Proc::capture/run/exec, or those after the directory for captureIn/runIn/execIn |
env |
the variable name | System::getenv("NAME"), Config::getEnv("NAME", …) |
read/write |
the path | the path argument of Files operations, a file"…" resource |
An operand counts as known when it is a string literal; a concatenation or
interpolation that starts with a literal ("https://api.github.com/search?q=" + q —
only a URL's host is read from such a prefix, and only when the literal runs past it); a
local val assigned once from either; or System::getenv("NAME"), which is shown as
$NAME (a webhook URL kept in an environment variable prints as
net $SLACK_WEBHOOK_URL). User-info in a URL (user:password@) is never printed.
Each operand gets its own line, with the effect name repeated, in the order the calls
are found — the tool's own body first, then the methods it calls.
An exec line shows the whole command: literal arguments verbatim, and … in place
of each argument that is not an exact literal (a parameter, a loop variable,
"updated:>=" + since), several in a row collapsing into one. So
Proc::capture("gh", kind, "list", "--repo", "onion-lang/onion") plans as
exec gh … list --repo onion-lang/onion, which is how digest.on above gets its
line. A command longer than 72 characters is cut and ends in …; an argument holding
whitespace or a quote is shown single-quoted. The same command reached from two call
sites is listed once.
The list is a lower bound. Anything else — a parameter, a var, a value computed at
run time, a literal passed into a helper's parameter (arguments are not followed into
the callee) — is not guessed. When a call site of an effect has an operand the analysis
could not read, the known operands are followed by a (operand not statically known)
line for the same effect, so a partial list never reads as a complete one. Under a
parameter-bound capability (write(out)) the derived from line is unchanged; literal
operands of the same effect elsewhere in the body are listed beneath it.
The same facts appear in --contract as one extra key per tool, present only when
something is known — every existing key keeps its value and position:
unresolved is true when at least one call site of that effect had an operand the
analysis could not determine. An exec entry also carries commands, each call site's
full argument vector, with null for an argument that is not a literal — known stays
the list of command names:
"exec":{"known":["gh"],"unresolved":false,"commands":[["gh",null,"list","--repo","onion-lang/onion","--state","all","--search",null,"--json","number,title,author,url","--limit","100"]]}
Libraries can classify their own calls¶
A call into a library jar is unknown unless something says otherwise — and the
library itself can. A jar on the compile classpath (-classpath, a //> using dep
directive, a project's [dependencies]) may ship an effect table at
META-INF/onion/effect-table.txt, in the format of the built-in table, with an optional
fixed operand per effect:
With that jar on the classpath, a tool calling Llm::ask(...) declares
requires { net, env, console } — no unknown — and its plan names the host and the
variable even though neither appears in the script:
$ onion ask.on "What is Onion?" --plan
plan: `ask` would
net api.anthropic.com
env ANTHROPIC_API_KEY
console
(nothing was executed; operands are the arguments the effects are
derived from, not necessarily the exact paths or hosts touched)
The fixed operands also join --contract's staticOperands, alongside any read off
literals. A library may classify only classes whose class file is in its own jar
(other lines are ignored, with warning W0018); for onion.* and java.* classes a
built-in entry always wins; when two jars classify the same class, the one earlier on
the classpath is used; and a table that does not parse is ignored whole, with warning
W0017 naming the jar and line. The effects reference has the
full line grammar and rules.