· 4 min read

Someone else owns main

Roc applications hand main! to a platform that owns startup, effects, allocation, and exit — a tenancy agreement written in checked syntax.

The inside of an old railway signal box, with rows of red, blue, black, and brown control levers in front of telephones and green cabinets.
Malcolm Morley, Public domain

A Roc program opens with a small act of submission. The first line picks a platform, and that choice dictates everything after it: what the application must provide, what it may import, which effects exist at all. The main! in the echo example below is not an entrypoint — it is a value the application hands over, typed to someone else's specification.

main.roc
app [main!] { pf: platform "./echo-platform/main.roc" }import pf.Echomain! = |_args| {    Echo.line!("Hello from Roc")    Ok({})}

I think this is the most honest move in recent language design. Your program was always a guest — libc called your main, the JVM owned your threads, the browser owned your loop — and Roc simply writes the tenancy agreement in syntax the compiler checks. Even the Ok({}) at the bottom is dictated from outside, since the platform demands a Try and its adapter decides what your success turns into.

The two headers meet at a type#

Here is the landlord's side, from Roc's echo platform at commit a06fe3f. Six declarations, and none of them is build configuration — this is a division of power.

echo-platform/main.roc
platform ""    requires {} { main! : List(Str) => Try(_, [Exit(I8), ..]) }    exposes [Echo]    packages {}    provides { "roc_main": main_for_host! }    hosted { "roc_echo_line": Echo.line! }
  1. 1The application's debt: a main! that takes the argument list and returns a Try carrying an exit effect.
  2. 2The application's entire visible world — nothing outside this list can be imported.
  3. 3The adapted entrypoint, sent back across the boundary under the symbol roc_main.
  4. 4Echo.line! resolves to a function the host implements in Zig.

Want to write a file from this application? You can't. No hosted symbol, no effect; the import does not exist to be written.

OwnerContractWhat stays inside
ApplicationChoose one platform; provide main!Domain logic and values permitted by that platform
Platform moduleRequire main!; expose Echo; adapt main! to roc_mainThe typed contract between Roc code and its host
Zig hostOwn the OS entrypoint; allocate the runtime; run RocProcess arguments, diagnostics, hosted functions, and exit code
The application, platform, and host each own a different boundary in the echo example.

The adapter under that header also settles what your program's result means as process behavior: success becomes exit code zero, an explicit Exit(code) passes through, and other failures get printed before one is returned. A framework calls your callback after a conventional main() and hopes you behave — Roc puts the same split behind a checked interface.

The host has the actual keys#

The other side of the contract is ordinary Zig, and the same commit lets you read it. The native entrypoint reads process arguments, sets up a 128 MiB fixed-buffer allocator, installs diagnostics, and calls into the shared runner, which registers Echo.line! and finally runs the compiled Roc code.

  1. Promise

    The application supplies main!

    Its type is the requirement declared by the chosen platform.

  2. Adapt

    The platform exports roc_main

    main_for_host! converts the application's Try result into an I8 exit code.

  3. Start

    The Zig process enters first

    It reads arguments, installs diagnostics, and allocates the runtime arena.

  4. Dispatch

    The host invokes Roc

    The runner registers Echo.line! and executes the compiled entrypoint.

Control crosses the application/platform contract before the host executes the Roc entrypoint.

You can imagine where this goes — a browser host with its own scheduling, a server host whose allocator lives and dies with a request — though the echo example proves none of that, and I would not promise it on one pinned commit. What the source does establish is narrower: the host owns the process and its resources, and the application receives exactly the capabilities its platform wrote down.

A checked boundary can still become a kingdom#

Now the uncomfortable part. Selecting a platform concentrates power in it: its API determines every available effect, its adapter defines process behavior, and its host joins your trusted computing base. Since two platforms need not even expose the same application type, replacing one is closer to changing frameworks than to swapping a library — the checking that makes the boundary safe also raises the cost of crossing to a different one.

Whether a mature platform ecosystem makes that price tolerable is a question the echo example cannot answer. It does show precisely where the bill accumulates, down to the last line of the native entrypoint: Zig takes the code the runner returned and calls std.process.exit(code). By then your function is long gone.