loom
Luce LuciaOS

The host

Luce computes. It has no way to reach the world on its own: no system calls, no libc, no ambient file handles, no way to write a byte anywhere. Every effect a program can have is a service the host hands it, and loom is the host that hands over all of them.

That is not a sandbox bolted on afterwards. It is the shape of the language. A Luce program handed no host is a program that computes and touches nothing — not because it was stopped, but because there was nothing there to call.

The whole surface

This is all of it. There is no second mechanism and no escape hatch.

Console

BuiltinAnswersWhat loom does
print(text)A line on standard output
print_error(text)A line on standard error, sanitized
read_line(prompt)string?Writes the prompt, reads a line; none at end of input

Files

BuiltinAnswersWhat loom does
file_read(path)string!The whole file, as text
file_write(path, content)!Truncate and write, then sync
file_append(path, content)!Add to the end, creating the file if needed
file_delete(path)!Remove it
file_rename(from, to)!Move it
file_exists(path)boolA question, never a guard
dir_list(path)list(string)!The names in a directory, unsorted, without . and ..

Paths are relative to the directory loom was started in. The file operations answer ! — the fallible shape — because the world decides and no non-racy check stands in for the result: a caller writes try to pass the failure on or catch to handle it, and ignoring one is a compile error rather than a silently dropped boolean. file_exists is a question you may ask; it is not a way to make the next call safe.

Two host policies live here and are worth knowing. A file must be valid UTF-8, because its bytes become a Luce string and the language promises that slicing is checked against character boundaries — a half-read JPEG would make that a lie. And there is a size cap of 64 MB, because file_read reads a whole file into one buffer, and without a cap a program could be handed the machine's memory by naming a path. Either way the read fails honestly rather than half-succeeding. Data is not otherwise touched: no BOM is stripped and no line ending is rewritten, so a program that reads a file and writes it back gets the same bytes out.

The terminal

BuiltinAnswersWhat loom does
term_rows() term_cols()longThe screen size now
term_clear()Begin a frame
term_move(row, col)Position the cursor, 0-based
term_style(fg, bg, bold)256-colour; -1 means the terminal's default
term_write(text)Draw text — sanitized
term_flush()Present the frame
key_read()string?Block for one key; none at end of input
key_text()stringThe payload of the last "text" key

The host owns raw mode, the alternate screen, frame buffering, and every escape byte. A program says "draw this at that position in that colour"; loom decides what bytes that is. Raw mode engages lazily on the first terminal builtin, so a program that never draws never touches the terminal's settings — and it is always restored before a trap is reported, and before a read_line, because a line read and a raw key loop are two ways to own one standard input and only one of them can be live.

Both key_read and sleep_ms present the pending frame before they block, so a draw loop needs no explicit flush and a program that draws and then waits is never a program whose last frame is stuck in a buffer.

Key names

key_read() answers a name from a closed set, so a program branches on words rather than on escape sequences:

NameMeaning
"text"A printable character; key_text() carries it
"enter" "tab" "backspace" "delete" "escape"As named
"up" "down" "left" "right"Arrows
"home" "end" "page_up" "page_down"Navigation
"ctrl_a""ctrl_z"A control key

Running out of input is not one more name in that set. It is none — the whole reason key_read answers an optional — because an "eof" among "enter" and "up" would be a value a program can fall past without the compiler saying so, and a draw loop that falls past it asks for the next key forever. That is not hypothetical: loom edit with standard input on /dev/null once never returned, at 97% of a core.

keys.luc
# A whole terminal program: draw a frame, wait for a key, draw again.
# The host owns raw mode, the alternate screen and every escape byte.

func main():
    var last = "press a key"
    while true:
        term_clear()
        term_move(0, 0)
        term_style(214, -1, true)
        term_write("loom")
        term_style(-1, -1, false)
        term_write("  " + string(term_cols()) + "x" + string(term_rows()))
        term_move(2, 0)
        term_write(last)
        term_move(4, 0)
        term_write("Ctrl-Q quits")

        let name = key_read()
        if name == none:
            return
        elif name == "ctrl_q":
            return
        elif name == "text":
            last = "text: " + key_text()
        else:
            last = "key: " + name
Shell
$ luce check keys.luc
keys.luc: ok
$ loom luce keys.luc

The clock, the environment, the machine, and stopping

BuiltinAnswersWhat loom does
clock_ms()longA monotonic reading — only differences mean anything
sleep_ms(ms)Wait at least that long
env(name)string?One environment variable; none when unset
os_total_memory()longBytes the machine has
os_available_memory()longBytes it could still hand out
os_cpu_count()longLogical processors
exit(status)never returnsEnd the run with that number

clock_ms is monotonic and not a calendar: a clock an administrator can move backwards would break even the promise that differences mean something. There is no wall clock, because dates are a library that does not exist yet. sleep_ms of a duration that has already elapsed — zero, or the negative one deadline - clock_ms() produces on a slow frame — is not a failure; there is no time left to wait, so the call returns. A frame-pacing loop that trapped only on a slow machine would be a correct program made flaky by its host.

The three os_* facts are plain numbers, and a host that cannot tell refuses rather than answering zero. Inventing a number is the one thing this boundary will not do.

The command line is not a service

A program's arguments are main's parameter, not something it asks for:

hello.luc
func main(args: list(string)):
    print("hello, loom")
    for name in args:
        print(name)

The list is owned by main's scope and freed when it returns. A program that ignores its arguments writes func main(): and says nothing false. Because the arguments are handed to a program rather than asked by it, the host gate below does not cover them, and a host with nothing to offer supplies an empty list instead of refusing.

Fail-closed

Every service in the table above is optional in the boundary's design, and a service the host does not offer traps host_unavailable rather than touching anything. There is no default implementation, no best-effort fallback, and no silent success.

loom withholds nothing: it fills every slot, because its whole purpose is running programs against a real machine. But the mechanism is what makes the language's promise true. A caller that provides a host with only print gets a program that prints and cannot open a file — the same program, unmodified, with the difference made at the boundary rather than by inspecting the program.

There is a second gate, one step earlier. Host builtins are gated at compile time: a program compiled without allow_host that names print is refused with a luce.sema.host diagnostic rather than compiled into something that will trap later. Both loom and luce compile with the gate open, because both exist to run programs on a real machine; an embedder that wants pure computation compiles with it shut.

Program text cannot forge the terminal

Anywhere program text reaches a channel loom owns, it is rewritten first: term_write, a read_line prompt, print_error, and the message of a trap or an error. Control bytes become ?. A program can never clear your screen, move your cursor, set your window title or forge the terminal's state through a channel it does not own.

Standard output is deliberately not sanitized. It is the program's own channel and may be a pipe or a file, where an escape sequence is just bytes and rewriting them would corrupt data. The rule is about channels loom shares, not about censoring programs.

That rule reaches into failure reporting too, which is the last place anyone looks: a trap message is whatever the program said, and it is printed at the moment the screen has just been restored. chr(27) + "[2J" in a trap message once cleared the terminal. Frame names go the same way — a function name is an identifier and cannot carry one, but a source name is a path the world chose.

The same boundary everywhere

The services are a table of function pointers an artifact is handed (LuceHost, the published host ABI). Its fields are append-only and never reordered, and any change to one bumps a version that an artifact carries — which is why an artifact built against an older boundary is refused by name rather than called with the wrong table.

The consequence worth stating: a program's behaviour does not depend on who started it. Under loom, as a standalone binary, or under the test suite's oracle, it meets the same services, reports a failure in the same words, and exits with the same number.