Synsemadocsv0.6.xENES

Language

Modules (use / export)

Split code across files. export makes a task/type/let/enum/routes public; anything else stays private. A module is a namespace of its exports, imported under an alias (keys(math) lists them).

The module — mathlib.syn:

export task square(n)
    give n * n

export let VERSION be "1.0"

export let STATS be {"calls": 0}

export task counted_square(n)
    set STATS["calls"] to STATS["calls"] + 1
    give n * n

task private_helper()        -- no `export` → not visible to importers
    give 42

The entry that imports it:

modules.syn
-- Doc example: modules (use / export). Imports ./mathlib.syn under the alias `math`.
intent: "doc example: modules"
use "./mathlib.syn" as math

print("math.square(5) = " + text(math.square(5)) + ",  VERSION " + math.VERSION)

-- The message of the error `f` raises, or nothing when it does not raise.
task error_of(f)
    try
        f()
    recover err
        give err
    give nothing

test "import an exported task and let (private_helper is NOT visible)"
    assert_eq(math.square(5), 25)
    assert_eq(math.VERSION, "1.0")
    assert_eq(keys(math), ["square", "VERSION", "STATS", "counted_square"])
    assert(contains(error_of(() => math.private_helper()), "export task private_helper"))

test "an exported let is ONE variable: the module's tasks and the importer see the same one"
    let before be math.STATS
    math.counted_square(3)
    assert_eq(math.STATS["calls"], before["calls"] + 1)
    set math.STATS["calls"] to 100
    math.counted_square(3)
    assert_eq(math.STATS["calls"], 101)

test "let snap be mod.X is a snapshot (a value)"
    let snap be math.STATS
    math.counted_square(2)
    assert_eq(math.STATS["calls"], snap["calls"] + 1)

test "set mod.X rebinds the export; tasks and new names cannot be set from outside"
    set math.VERSION to "2.0"
    assert_eq(math.VERSION, "2.0")
    set math.VERSION to "1.0"
    assert(contains(error_of(() => set_task()), "cannot replace the module task 'square'"))
    assert(contains(error_of(() => set_new()), "module has no export 'NEW'"))

test "every path to the module reaches the same state; parallel workers get their own"
    let mods be {"m": math}
    set mods.m.STATS["calls"] to 7
    assert_eq(math.STATS["calls"], 7)
    let r be parallel_map((x) => math.counted_square(x), [1, 2])
    assert_eq(r, [1, 4])
    assert_eq(math.STATS["calls"], 7)

task set_task()
    set math.square to 1

task set_new()
    set math.NEW to 1

Rules§

Module state — an exported let is one variable§

Lists and maps are values everywhere else, but a module is not a value: an exported let is one variable, the same one the module's own tasks read and write.

set math.NEW to 1 → module has no export 'NEW'; set math.square to 1 → cannot replace the module task 'square' from outside the module.

Every path to a module reaches the same state: two use of the same file, a re-export (export let L be math in another module), or a module kept in a map. Under serve and parallel_map each request or worker has its own copy of the module's state; to share across them, use state_*, SQL or remember.

export routes — route groups a serve can mount§

A big site doesn't have to be one big serve block. A module exports a routes group; the entry mounts it (bodies can call the module's private helpers by simple name):

-- shop.syn
task fmt(n)                        -- private
    give "$" + text(n)

export routes shop
    route "GET /shop"
        give html("<h1>" + fmt(99) + "</h1>")
    route "POST /shop/buy"
        expect body {item: text}
        give created(json of request)
-- app.syn
use "./shop.syn" as shop
serve on 8080
    mount shop.shop                -- or: mount shop.shop at "/store"

Groups accept route entries with their own rate_limit and timeout (engine v0.6.19+: a mounted route gets its own rate zone, and a mount prefix is another zone — before v0.6.19 both were refused inside a group); stream and socket routes work inside a group too since engine v0.6.20 (mounted like a direct route, own rate zone — before, synsema check and serve refused them). A route in a group, or the whole group, can be private (v0.6.20+: the line private at the top of the route body or of the group): served, but left out of /llms.txt, /openapi.json, /sitemap.xml and /docs — see Discovery. A mounted requires auth still demands auth with on the serve block, validated when the serve is built. See HTTP server and Build a website.

Trusted modules§

Engine v0.6.46+. A trusted module is a module the host loads — written by a person — that code the host does not trust (a program written by a model, say) can only call. Each exported task runs with its own requires, never the caller's. So a program under a ceiling without declassify cannot publish a customer's data, but it can call t.reply(customer, text), whose policy declassifies only that customer's own address.

-- tools.syn — written by a person, loaded by the host
export task reply(customer, body)
    require declassify("customers")
    let dest be declassify(customer.email, "the reply goes to the customer's own address")
    give {"to": dest, "length": declassify(length(body), "the length of a reply is public")}
-- app.syn — may be written by a model: under this ceiling it cannot declassify by itself
use "trusted:tools" as t
let c be private({"email": "ana@example.org", "name": "Ana"}, "customers")
print(t.reply(c, "your order shipped"))       -- {to: "ana@example.org", length: 18}
synsema run --labels --cap-set stdout --trusted tools=./tools.syn \
    --trusted-cap-set "stdout,declassify=customers" app.syn

The same program with print(declassify(c.email, "direct")) stops at that line: `label_violation: declassify is not granted for what this value is private to (customers) …`.

tools.syn
-- Doc example: a trusted module. The HOST loads it (`--trusted tools=./tools.syn`); the program can
-- only call it. Each exported task runs with its own `require`s, bounded by `--trusted-cap-set`.
export let config be {"limit": 3}

export task reply(customer, body)
    require declassify("customers")
    let dest be declassify(customer.email, "the reply goes to the customer's own address")
    give {"to": dest, "length": declassify(length(body), "the length of a reply is public")}

export task limit()
    give config.limit

export task shout(t)
    give upper(t)

export task apply_to(f, x)
    give f(x)
trusted.syn
-- Doc example: trusted modules. Runs ONLY as
--   synsema test --labels --cap-set stdout --trusted tools=docs/0.6.x/examples-trusted/tools.syn \
--       --trusted-cap-set "stdout,declassify=customers" docs/0.6.x/examples-trusted/trusted.syn
-- The program's own ceiling (`--cap-set stdout`) has no `declassify`: it publishes the customer's
-- address only through the tool a person wrote.
intent: "doc example: trusted modules"
use "trusted:tools" as t

let CUSTOMER be private({"email": "ana@example.org", "name": "Ana"}, "customers")

-- A program task that shadows a builtin: the tool never sees it.
task upper(x)
    give "HIJACKED"

task mine(x)
    give x

-- To offer a trusted tool to a model, wrap it in a task of the program.
task reply(customer: map, body: text)
    give t.reply(customer, body)

-- The message of the error `f` raises, or nothing when it does not raise.
task error_of(f)
    try
        f()
    recover err
        give err
    give nothing

test "the tool publishes what its policy allows"
    let r be t.reply(CUSTOMER, "your order shipped")
    assert_eq(r, {"to": "ana@example.org", "length": 18})

test "the tool never sees the program's names"
    assert_eq(t.shout("hi"), "HI")

test "arguments are data, not code"
    assert(contains(error_of(() => t.apply_to(mine, 1)), "a trusted tool takes data, not code"))
    assert(contains(error_of(() => t.apply_to((x) => x, 1)), "a trusted tool takes data, not code"))

test "the program changes only its own copy of the exports"
    set t.config to {"limit": 99}
    assert_eq(t.config, {"limit": 99})
    assert_eq(t.limit(), 3)

test "trusted tasks take positional arguments; wrap them to offer them to a model"
    assert(contains(error_of(() => t.shout(t = "x")), "does not accept named arguments"))
    assert_eq(tool_spec(reply, "Reply to a customer").params, ["customer: map", "body: text"])
    assert_eq(reply(CUSTOMER, "hi").to, "ana@example.org")

Flags§

FlagWhat it does
--trusted <name>=<path>loads <path> as trusted:<name>; repeatable; on synsema run and synsema test. name is letters, digits, _ or -; the path is relative to the working directory
--trusted-cap-set "<list>"the ceiling of every trusted module, same syntax as --cap-set

In @synsema/wasm the options are trusted: {tools: source} and trusted_ceiling (WASM). The errors of the flags: `--trusted takes name=path (a name of letters, digits, '_' or '-'), e.g. --trusted tools=./tools.syn, --trusted tools: cannot read ./x.syn: …, --trusted tools: the name is given twice. synsema serve refuses the flag: --trusted runs under synsema run and synsema test for now (a trusted tool is not isolated in the workers of serve yet)`.

What a trusted task may do§

What the program can and cannot do with it§

use "trusted:tools" as t

task reply(customer: map, body: text)
    give t.reply(customer, body)

let catalog be [tool_spec(reply, "Reply to a customer")]

A call or call_tool whose argument map holds a private value gives a private result, even when the tool declassified what it returns; call the wrapper directly (reply(c, body)) to keep the public result.

What it does not do yet§

Each of these is an error inside a trusted tool, and the message says why: defining or spawning an agent, parallel_map, cron_every/cron_after, a use of a file module — all of them would run in another interpreter next to the program's names — and render, with its includes and layouts (the template file lives where the program can write it, and it would run with the tool's capabilities: build the text in the tool). For example: parallel_map: a trusted tool cannot fan out yet — its workers would run with the tool's capabilities next to the program's names; loop over the items in the tool.

With trusted modules the whole program runs on the tree-walker, without the bytecode VM. Measured: a loop of 3 million iterations, 0.07 s → 0.8 s.