What a Deploy Is Allowed to Do
A code review shows what a change says. It rarely shows what the change can now reach. A library that formats a number in one release can open a socket in the next, and the diff that brings it in is one line with a version number on it.
March keeps a record of what every part of a program may touch, and checks it at each step between a pull request and a running machine: when a dependency arrives, when the program is compiled, when it is deployed, and when a node is asked to run it. This page follows one small update past each of those checks.
The program is
examples/lab_app:
three roles on four machines, the same one
From a Protocol to a Cluster follows.
Nothing here needs that page first.
What a program may touch
Every March module says what it needs from the world outside it, on lines that begin
needs. This is the head of Probe, the lab app’s module that writes each node’s health
reading to a file every few seconds:
mod Probe do
needs IO.FileWrite
needs IO.Clock
needs IO.Spawn
...
One square for each module in the lab app's program: 158 of them. A few were written for the app. The rest are the library code it links.
118 of them declare nothing. They cannot open a file, read the clock or print a line. They take values and return values.
That is checked, not promised. A module that does any of those things without a
needs line does not compile.
15 reach only the process they run in (the console, the clock, random numbers, tasks, shared mutable state) or a session they are handed.
25 can reach a file, the network or another process. For anyone auditing this program, these are the modules to read.
3 of those were written for the app, and each names what it
uses. Probe writes a file, reads the clock and starts a task. None of the
three names the network.
Suppose Probe starts sending its reading to a collector, and nobody adds
the line.
fn report(home : String) : () do
let line = "at " ++ int_to_string(unix_time_ms()) ++ "\n"
let _ = tcp_connect("collector.example", 4317)
let _ = File.write(home ++ "/lab-probe", line)
()
end
`report` opens a network connection, but module `Probe` declares no `needs IO.NetConnect`. [cap_needs]
What the compiler prints
-- CAPABILITY -------------------------- undeclared.march
`report` opens a network connection, but module `Probe` declares no `needs IO.NetConnect`. [cap_needs]
hint: add this to the module body —
needs IO.NetConnect
9 | let _ = tcp_connect("collector.example", 4317)
^^^^^^^^^^^
fix (`forge fix` applies it):
3 | mod Probe do
4 + needs IO.NetConnect
run `march --explain cap_needs`
Declaring is not the same as being allowed. Authority enters a program in one place,
the parameters of main, and can only be narrowed from there. This
main is handed files and the clock, and a module under it declares the
network and uses it.
fn main(_files : Cap(IO.FileWrite), _clock : Cap(IO.Clock)) : () do
Probe.report("/tmp")
end
`main` is granted `Cap(IO.Clock)` + `Cap(IO.FileWrite)`, but the program reaches `IO.NetConnect` here. The grant is a ceiling on the WHOLE program — declaring `needs IO.NetConnect` does not raise it. [cap_grant_exceeded]
What the compiler prints
-- CAPABILITY ----------------------------- ceiling.march
`main` is granted `Cap(IO.Clock)` + `Cap(IO.FileWrite)`, but the program reaches `IO.NetConnect` here. The grant is a ceiling on the WHOLE program — declaring `needs IO.NetConnect` does not raise it. [cap_grant_exceeded]
help: add a `Cap(IO.NetConnect)` parameter to `main` (e.g. `fn main(…, _cap_netconnect : Cap(IO.NetConnect))`), or widen the whole grant to `Cap(IO)`, or remove the use.
15 | let _ = tcp_connect("collector.example", 4317)
^^^^^^^^^^^
`main` calls `report` here:
22 | Probe.report("/tmp")
^^^^^^^^^^^^
`main` is granted only `Cap(IO.Clock)` + `Cap(IO.FileWrite)` here:
21 | fn main(_files : Cap(IO.FileWrite), _clock : Cap(IO.Clock)) : () do
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
fix (`forge fix` applies it):
21 - fn main(_files : Cap(IO.FileWrite), _clock : Cap(IO.Clock)) : () do
21 + fn main(_files : Cap(IO.FileWrite), _clock : Cap(IO.Clock), _cap_netconnect : Cap(IO.NetConnect)) : () do
run `march --explain cap_grant_exceeded`
The lab app has no hand-written main. The compiler generates one from a topology file
and narrows what it holds for each group of machines. That is two sections down. First,
the update.
A dependency asks for more
The lab app has no dependencies, so to follow an update this page gives a copy of it one.
gauge is a small library that formats the reading Probe writes:
mod Gauge do
fn line(live : Int, at : Int) : String do
IOList.to_string(~H"live ${live}\nat ${at}\n")
end
end
In the copy, the line of Probe that built that string by hand becomes
let line = Gauge.line(live_allocs(), unix_time_ms()). Nothing else in the app changes, on this page or in the repository:
the copy is made by the script that generates the page’s numbers.
One more square, and it reaches nothing. forge audit --record writes
down what each dependency declares, in a file you commit:
mode = "declared" [[package]] name = "gauge" caps = []
Version 1.1 also reports the reading to a collector.
mod Gauge do
needs IO.NetConnect
fn line(live : Int, at : Int) : String do
let text = IOList.to_string(~H"live ${live}\nat ${at}\n")
let _ = tcp_connect("collector.gauge.example", 4317)
text
end
end
Its square moves. It is now one of the modules that can reach the network.
forge deps compares what the new version declares with the committed
file, and stops with exit status 1:
resolving 1 dependencies... gauge: found at ../gauge capability changes against forge.caps.lock: ! gauge — now ALSO needs: IO.NetConnect error: gauge asks for capabilities forge.caps.lock does not grant it; forge.lock was left as it was (your forge.toml edit is kept). Review the change above, then re-run `forge deps --accept-caps` to lock it and record the new set.
The lock file is left as it was. Had the new version dropped a capability instead, the update would have gone through.
Nothing forbids a library from using the network. The rule is that it cannot start
to without someone saying yes. The yes is
forge deps --accept-caps, and it lands in the committed file, where a
reviewer sees it:
mode = "declared" [[package]] name = "gauge" caps = ["IO.NetConnect"]
Accepting the update changed no line of the app. Probe calls the same function as
before, and the program still compiles. What changed is what the machines that run
Probe can reach. The rest of the page is how March keeps track of that.
From a file to machines
The lab app has no main and no deployment scripts. It has this file:
[roles]
"Order.Stock" = { body = "LabApp.Work.stock", capacity = 32 }
"Order.Ledger" = { actor = "LabApp.Work.LedgerActor", capacity = 64, place = { on = "books", count = 1 } }
[pool.ingress]
start = "LabApp.Ingress.start"
[pool.work]
start = "LabApp.Work.start"
serves = ["Order.Stock", "Order.Ledger"]
[control]
candidates = "control"
[drain]
soft_ms = 3000
hard_ms = 8000
[roles] binds each role to the code that plays it: a function for Stock, an actor for
Ledger. capacity is how many sessions one machine serves in that role at once. Past it
the machine answers that it is full, and the caller tries another machine’s offer.
place says which machines hold the role. A second, shorter file names the lab’s four
machines and labels them.
Two pools of machines. ingress is one machine,
lab-1. work is
3: lab-2, lab-3, lab-4. The labels on them
come from the lab's file.
Roles are placed by the file, not by code. Shop goes where the
ingress pool starts it. Stock has no placement rule, so every
machine that serves it offers it. Ledger is placed
on = "books", count = 1: one machine carrying that label holds it.
Each dot is one message of a session. Shop asks Stock, Stock answers and tells
Ledger, Ledger confirms to Shop. The main that starts all of this was
generated from the file.
This is a simulation, slowed so it can be watched: about 2.2 sessions a second.
Stop the machine holding the Ledger. Once the cluster has given up on it, the other
books machine takes the role. Sessions are refused in between.
Nobody edited a file or restarted anything. The other page goes through what the cluster does here.
The machine is back and offers Stock again. Now the part this page is about. For each pool, the compiler works out what the pool's own code can reach: its startup hook, its roles and everything they call.
Nobody wrote these lists. They come from the same needs lines as the
squares.
forge writes each machine's firewall from the same file. The cluster
port, 7946, is open between every pair of machines:
12 rules. The control port, 7947, is open only between
the 3 machines labelled control:
6 rules.
The firewall does not keep the pools apart. Certificates do: each node's certificate names the roles it may play.
When a machine is set up, forge host init also leaves it a policy file
made from its pool's list. The node checks new code against that file.
It is the last check a patch meets, and the next section gets there.
The patch that asked for more
Back to gauge 1.1. It was accepted and the app compiles. The next
step is to deploy it to the running cluster. March can do that hot: new code is loaded
into the live processes, with no restart.
Work out the pools' lists again and both have grown by one:
IO.NetConnect.
It reached them through Probe, which both pools start. Neither
Work nor Ingress mentions the network anywhere.
forge deploy hot makes that comparison before it builds anything,
against the lists recorded when the last deploy completed:
error: hot deploy would widen pool ingress's derived capabilities (D26)
last deployed caps: IO.Clock, IO.FileRead, IO.FileWrite, IO.Mut, IO.Process, IO.Spawn (env lab)
new version adds: IO.NetConnect
error: hot deploy would widen pool work's derived capabilities (D26)
last deployed caps: IO.Clock, IO.Console, IO.FileWrite, IO.Mut, IO.Process, IO.Spawn (env lab)
new version adds: IO.NetConnect
A hot deploy may only narrow authority. To authorize this widening, re-run with:
forge deploy hot --env lab --grant-cap IO.NetConnect
error: pool capability widening — deploy aborted
The patch never leaves your machine. forge deploy, which a cluster like
the lab's uses, blocks the pool in its plan for the same reason.
A pool can also have its list written down in the topology file:
caps = ["IO.Clock", "IO.Console", "IO.FileWrite", "IO.Mut", "IO.Process", "IO.Spawn"]
Then the compiler holds the pool to it, and the same update fails the build:
error: topology: pool "work": its hook LabApp.Work.start reaches `IO.NetConnect`, beyond the pool's written caps [IO.Clock, IO.Console, IO.FileWrite, IO.Mut, IO.Process, IO.Spawn]
To go on, someone has to name the capability, with
--grant-cap IO.NetConnect:
granted widening: pool ingress gains IO.NetConnect (via --grant-cap IO.NetConnect) granted widening: pool work gains IO.NetConnect (via --grant-cap IO.NetConnect)
The grant is signed along with the patch, and each node's audit log records who deployed what, with which capabilities. In a cluster like the lab's the change travels as one signed release: to the control leader, and from there to every machine.
Each node checks the patch again, against its own policy file. A node whose file does not list the capability refuses to activate the function, whoever signed it, and stays as it was. In the reference's worked example a node answers:
FAILED Server.handle: ERR cap_policy IO.FileWrite — deploy rejected by node capability policy
This check does not depend on forge. It holds when the deploy pipeline
is bypassed, or the wrong build is sent to a restricted machine.
A node reads its policy once, when it starts. Changing it means changing the file on that machine and restarting the node. A hot patch cannot do it.
After that, the same signed patch is admitted. The new capability is now part of what the pools may reach, and the next update is compared with that.
The last line
Everything so far is checked before code runs. The node’s gate trusts the list a patch carries, and that list is written by the compiler that built the patch.
For a check that does not rest on that, March can ask the operating system. A binary
compiled with --cap-sandbox puts a deny-by-default sandbox on itself before any of its
own code runs, built from what its main was granted (Seatbelt on macOS, seccomp on
Linux). It cannot be loosened afterwards, so it applies to hot-patched code and to C code
as well. If the sandbox cannot be installed, the program exits instead of running
unconfined.
It is opt-in, and the operating system cannot tell every capability apart: the clock, the
console, random numbers and task spawning are reported as advisory, not enforced.
Capability Enforcement has the table.
In a topology, a pool marked isolate = true gets its own build and its own sandbox. The
lab’s pools are not marked, and the lab was not run under the sandbox for this page.
What this does not do
- The node’s gate trusts the patch’s own list. Anyone holding the signing key can build a patch that claims less than it does. The gate catches mistakes, and a compromised pipeline that still uses the real compiler. It is not a sandbox.
- Without a policy file there is no node gate.
forge host initinstalls one. A node started by hand with noMARCH_DEPLOY_POLICYadmits every signed patch. - A capability says what may be reached, not what is done with it.
IO.NetConnectallows a health report and a data leak equally. - The first deploy has nothing to compare with. The widening gate lets it through, with a note, and records the lists for next time.
- A role served through a topology file is not yet held to its own grant. The line
role Stock needs IO.Mutis enforced when a role is run directly. Through a topology, the pool’s list is the limit that holds. This is a known gap. - The firewall does not separate pools. It closes the cluster to everyone else.
The reference chapters go further on each part: Capabilities, Capability Audit, Topology, Hot Code Reload and Cluster Certificates.
How the numbers on this page were measured
Every count, excerpt and tool message above is generated, not typed. One script
regenerates them all, and this page was last generated at commit
0b02b0953 on 2026-11-09:
scripts/gen-authority-data.py
- The squares: the
module_capslist frommarch --emit-core-ast examples/lab_app/src/lab_app.march, one entry per module the program links, grouped by the capabilities it declares. - Compiler messages:
march --checkon each program indocs/assets/explainer/authority/programs/. - The dependency: the script copies the lab app, runs
forge addon thegaugefixture indocs/assets/explainer/authority/, replaces one line ofProbewith a call to it, and runsforge audit --record. It then swaps in version 1.1 and runsforge deps, with and without--accept-caps. - Pools, capabilities, ports and firewall rules:
forge topology export --json --env labandforge topology gen ufw --env labon that copy. - The refused deploy:
forge deploy hot --env labon the copy. That command compares against a record a completed deploy leaves. The script deploys nothing, so it writes that record itself, from the export made before the update. The written-list variant isforge buildon a second copy. - The node's answer: quoted from the worked example in Hot Code Reload. It was not produced by the lab app.
- Simulations: seeded (20261106). Their rules are tested in
test/explainer/; they are not recordings.