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:
-- 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 1Rules§
use "./path.syn" as alias— paths are relative to the importing file;.synonly, no URLs/FFI.../climbs inside the project (engine v0.6.20+): the boundary is the project root — the directory of the entry file — sosrc/site/pages.syncanuse "../core/i18n.syn"; a path that resolves above the root fails (module path escapes the project root; from the entry file itself the message stays the historicalescapes the importing directory). Containment is decided on absolute paths, socd proj && synsema run main.synis exactly as safe as running it by absolute path.synsema checkalso warns (exit code unchanged, v0.6.20+) when a top-levelletor anexport routesgroup carries the same name as ause … asalias — inside that scope the name resolves to the local, not the module.- Cross-file calls need the alias prefix:
math.square(5),math.VERSION. - Reading a name the module does not export is an error that suggests the fix:
the module has no export 'private_helper' — it is defined inside the module but not exported (writeexport task private_helperthere …). - Imports are cached (loaded once), transitive, and cycle-checked.
- A module must not have a top-level
requireorserve— those belong in the entry file. A per-taskrequireinside a module task is fine. synsema check entry.synresolves and parses the whole import graph (broken paths, cycles, forbiddenserve/requirein modules all fail the check).
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.
math.counted_square(3)updatesSTATSinside the module, andmath.STATSshows it.set math.STATS["calls"] to 100writes the module's variable, and its tasks see the change.set math.VERSION to "2.0"rebinds the export, likemath.VERSION = "2.0"in Python.let snap be math.STATSis a snapshot — a value that later changes do not touch.- From outside, a module gains no names and its tasks cannot be replaced:
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) …`.
-- 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)-- 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§
| Flag | What 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§
- Its capabilities are its own
requires, inside each exported task. A top-levelrequirein the module is a load error:module 'trusted:tools' must not have a top-level 'require': each exported task declares its own. Nothing comes from the caller: not its grants, not its--cap-set, not asandboxaround the call. A tool that usesnetwithoutrequire net("…")fails like any program, even if the program holdsnet; one that declares it under a--trusted-cap-setthat does not name it fails with… declared but above the host ceiling. declassifyin a trusted task is bounded by--trusted-cap-set. Withdeclassify=customers, it publishescustomersand nothing else. Without a--trusted-cap-setit declassifies like a program without a ceiling — any principal — so give the trusted modules a ceiling. (Measured on v0.6.46: the task'srequire declassify("customers")documents the policy, but the bound is the trusted ceiling — a task without thatrequirestill declassifies within it.)- It can
useanother trusted module (use "trusted:base" as base). A file module is an error: `a trusted module imports only other trusted modules (use "trusted:…"), not './x.syn': a file module sees the program's names and would run with the tool's capabilities`. - Its audit entries carry
context: "trusted:tools"andfile: "trusted:tools", and itsdeclassifylog lines sayat trusted:tools:<line>.
What the program can and cannot do with it§
- Arguments are data. A task, a lambda or a builtin among them — also inside a list or an
ok(…)— is an error, since it would run with the tool's capabilities:trusted:tools.apply: a trusted tool takes data, not code — an argument holds a task, which would run with the tool's capabilities; pass the values it needs instead. - It never sees the program's names. A program that defines
task upper(x)before or after theusedoes not change what the tool'supper(…)runs. - It cannot change the module.
set t.config to …,set t.config["limit"] to …, evenset t.reply to …or a newset t.extra to …, change only the program's own copy of the exports; the tool's tasks and anotherusesee the original. (A file module is the opposite: thereset math.STATS[…]writes the module's variable.) - It cannot load one itself. The name comes only from the host, and the program never sees the path —
print(t)shows{reply: builtin:trusted:tools.reply, …}. An unknown name is a load error: `no trusted module named 'tools': the host loads it (synsema run --trusted tools=./file.syn, ortrusted: { tools: source }in wasm); a program cannot load one itself. The same file without--trustedfails the same way, also undersynsema test(as a<setup>` failure). - Its tasks take positional arguments only.
t.reply(customer = c),call(t.reply, {…})andcall_tool(t.reply, {…})fail withtrusted:tools.reply() does not accept named arguments (got customer = …); pass it by position, andtool_spec(t.reply, …)is refused (tool_spec needs a task of the program, not the builtin 'trusted:tools.reply'). To offer a trusted tool to a model, wrap it in a task of the program and put the wrapper in the allow-list and the catalog:
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.