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
  ...
158 squares, one for each module of the lab app's program, in three groups: 118 that reach nothing, 15 that reach only the process, and 25 that can reach files, the network or another process.

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.

The same squares, with one more for the dependency.

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.

Four machines in two pools, with the roles each one holds, the capabilities each pool may reach and the firewall links between every pair of machines.

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.

The same four machines and the developer's machine. A patch that adds a capability is stopped at a gate before it leaves the developer's machine.

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 init installs one. A node started by hand with no MARCH_DEPLOY_POLICY admits every signed patch.
  • A capability says what may be reached, not what is done with it. IO.NetConnect allows 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.Mut is 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_caps list from march --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 --check on each program in docs/assets/explainer/authority/programs/.
  • The dependency: the script copies the lab app, runs forge add on the gauge fixture in docs/assets/explainer/authority/, replaces one line of Probe with a call to it, and runs forge audit --record. It then swaps in version 1.1 and runs forge deps, with and without --accept-caps.
  • Pools, capabilities, ports and firewall rules: forge topology export --json --env lab and forge topology gen ufw --env lab on that copy.
  • The refused deploy: forge deploy hot --env lab on 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 is forge build on 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.