Synsemadocsv0.6.xENES

Operación

WebAssembly

Desde v0.6.0 el intérprete también se distribuye como WebAssembly. El mismo lenguaje, los mismos .syn, las mismas capabilities deny-by-default — lo que cambia es quién ejecuta el programa.

¿Cuál necesito?§

init — querés el binario nativo, no wasm: curl -fsSL https://synsema.org/install.sh | sh, o npm i -g synsema (el mismo binario, distribuido por npm). Ver Quickstart.

Python o Go — en el navegador, en Node, en un runtime edge — querés el artefacto embebible**: el paquete npm @synsema/wasm (o el mismo .wasm con el glue de Python/Go). Tu app le entrega al intérprete un programa como texto y recibe su salida; tu app también decide qué puede tocar el programa (red, almacenamiento, un LLM). Más abajo.

coprocesador confidencial — querés el artefacto wasip1: un .wasm de línea de comandos que se comporta como synsema run sin instalar nada en el host. Sección siguiente.

Los dos artefactos van adjuntos a cada release (synsema-wasm-wasip1.wasm, synsema-wasm-web.wasm, cada uno con su .sha256); el embebible también está en npm. Nadie instala Synsema en el host: el .wasm es la unidad desplegable, como lo es una imagen de contenedor. Probalo sin instalar nada: el playground del sitio corre el intérprete en tu navegador.

El artefacto wasip1: jobs confidenciales, TEEs§

Bajá synsema-wasm-wasip1.wasm del release (verificá el .sha256) y corrélo con cualquier host WASI:

# correr un programa (wasmtime)
wasmtime run --dir . synsema-wasm.wasm programa.syn

# correr los bloques `test` de un archivo
wasmtime run --dir . synsema-wasm.wasm --test programa.syn

# leer el programa de stdin; config/secretos por env
wasmtime run --env ETH_KEY=... synsema-wasm.wasm -   < programa.syn

# techo del host — las MISMAS flags y el mismo parser que `synsema run`
wasmtime run --dir . synsema-wasm.wasm --sandbox programa.syn                   # techo = [stdout, time]
wasmtime run --dir . synsema-wasm.wasm --cap-set stdout,secret=ETH_* programa.syn

synsema-wasm [--test] [--sandbox | --cap-set <lista>] [--version] <archivo.syn | ->. El techo del host (--sandbox[stdout, time]; --cap-set = nombre o nombre=scope, separados por coma) es la misma defensa en profundidad que en synsema run: un require por encima se deniega, auto-grants incluidos. Una --flag desconocida es error (exit 2), nunca se toma en silencio como la ruta del programa. Códigos de salida: 0 ok, 1 error de runtime / test fallido, 2 uso o programa ilegible.

¿Sin wasmtime a mano? Node trae WASI: node examples/embed/node/run-wasip1.mjs synsema-wasm.wasm programa.syn corre el mismo artefacto (Node sigue marcando node:wasi como experimental; corre el binario entero).

Un job en TEE (un coprocesador confidencial que corre WASM dentro de un enclave y registra el resultado onchain) es entrada → cómputo puro → salida verificable. Eso es este artefacto: leer la entrada, computar, hashear/firmar el resultado con la clave sellada como secret bajo require sign, imprimir la salida. El manifiesto de capabilities es a la vez la historia de auditoría.

require secret("ETH_KEY")

let resumen be {"suma": sum([120, 180, 95])}
let cuerpo be json_encode(resumen)
let digest be decode(keccak256(cuerpo), "hex")
let addr be eth_address(secret("ETH_KEY"))
print(`resumen={cuerpo}`)
print(`keccak={digest}`)
print(`addr={addr}`)

El artefacto embebible: agentes dentro de apps hechas en otros lenguajes§

En JavaScript/TypeScript es una dependencia npm común y corriente — package.json, bundler, tipos incluidos (index.d.ts); nada raro:

npm i @synsema/wasm
import { Synsema } from "@synsema/wasm";

const syn = await Synsema.load(new URL("@synsema/wasm/synsema.wasm", import.meta.url));
await syn.ready();

const r = syn.run(`print(keccak256("hola"))`, { env: { KEY: "abc" }, ceiling: "sandbox" });
r.output;   // ["…"]  las líneas de print del programa
r.errors;   // []     los errores de parse/runtime son datos, nunca excepciones
r.audit;    // cada chequeo de capability, concedido o no — campos abajo

Cada entrada de audit es {capability, granted, source, reason, origin}. reason dice por qué: una denegación es No matching grant found (el programa nunca lo declaró), above host ceiling (--sandbox/--cap-set) (declarado, pero por encima de lo que presta el host) o Explicitly denied by …; un grant es Granted by <grant>, uno ambiente que otorgó el runtime es auto-granted by the runtime, y una lectura desde un bundle de synsema build es bundled asset (part of the program). origin dice quién puso la entrada: "program" (un require del programa o una llamada que hizo) o "runtime" (un grant ambiente — stdout/time/llm en una corrida no-segura, serve desde --port). Los grants ambiente que tienen éxito ahora también dejan rastro (source: "ambient", granted: true), así el audit muestra exactamente qué otorgó el runtime, no solo lo que rechazó. Así "este tenant quiso leer STRIPE_KEY" es origin == "program" && !granted, sin re-parsear el fuente. (El synsema run --audit json nativo streamea las mismas entradas más ts, context — qué CapabilitySet, p. ej. program/agent/request — y file/line; y run_program agrega above parent ceiling / above parent profile por lo que un hijo pidió más allá de su padre — ver Observabilidad.) La misma distinción llega al texto del error: una llamada por encima del techo dice declared but above the host ceiling — the program cannot fix this; the host must widen the ceiling, mientras que un require faltante sigue diciendo qué línea agregar.

Mover valor en el browser/edge. sign, spend, wallet y reveal son fail-loud: sin línea de audit, no hay operación. En nativo esa línea va a ~/.synsema/audit/<op>.log; un host wasm no tiene filesystem, así que la línea va al kv del host bajo el namespace audit (clave = sign.log, spend.log, wallet.log, reveal.log, texto append-only, jamás material de clave). Ofrecé kv y esas operaciones funcionan exactamente como en nativo — bajo el techo que vos fijás (sign=ETH_KEY, spend=USD, wallet=mnemonic); sin kv se niegan con this host provides no audit sink*. Una recursión desbocada es un error de runtime (maximum recursion depth exceeded), nunca un trap que descarte la instancia.

run{ok, output, errors, audit, llm_tokens}; test{passed, failed, lines}; check{ok, errors} (parse + la regla de declaración de memoria — a lo sumo un require memory, con nombre válido; no detecta un remember sin require memory: eso se deniega en run, en errors/audit, igual que el synsema check nativo); handle (abajo); version. env reemplaza al .env: secret("KEY")/env("KEY") resuelven de ahí. ceiling acepta la sintaxis de --cap-set ("stdout,secret=ETH_*") o "sandbox".

Vite, Next, Bun y Node resuelven new URL("@synsema/wasm/synsema.wasm", import.meta.url) (los bundlers copian el .wasm al build; Node lo lee del disco). Una página HTML suelta sin bundler puede importar el paquete desde un CDN ESM como https://esm.sh/@synsema/wasm — sirve para una demo, no para un proyecto (fijá versiones, no dependas de un CDN de terceros en runtime).

El programa es el mismo .syn que correrías nativo — llega como texto (syn.run(fuente)), sus líneas de print vuelven en output, y no corre ningún proceso: sin stdout, sin archivos. El mismo .wasm funciona desde Python (examples/embed/python, wasmtime-py) y Go (examples/embed/go, wazero, sin CGO): exporta una entrada (synsema_call, JSON entra / JSON sale) e importa tres funciones del host, así que cualquier runtime que cargue WebAssembly lo maneja con ~80 líneas de glue.

Lo que tu app presta: http, kv, llm§

El programa conserva su manifiesto; tu app decide qué recibe de verdad. Nada de lo que el host presta se alcanza sin el require del programa, y el ceiling del embebedor deniega por encima de lo que el host presta. Cada chequeo queda en audit.

const store = new Map();
const host = {
  http: (req) => ({ status: 200, headers: [["content-type", "application/json"]], body: "{}" }),
  kv: {
    get: (ns, k) => store.get(ns + "/" + k) ?? null,
    set: (ns, k, v) => store.set(ns + "/" + k, v),
    delete: (ns, k) => store.delete(ns + "/" + k),
    list: (ns) => [...store.keys()].filter((x) => x.startsWith(ns + "/")).map((x) => x.slice(ns.length + 1)),
  },
  llm: (op, prompt) => ({ content: "…", tokens: 12 }),   // acá va tu SDK
  log: (line) => console.log(line),
};

syn.run(`require memory("agenda")\nremember("preference", "modo oscuro", ["ui"])`, { host, filename: "agenda.syn" });
syn.run(`require memory("agenda")\nprint(recall(search="oscuro")[0]["content"])`, { host, filename: "agenda.syn" });
syn.run(`require net("api.example")\nprint(fetch("https://api.example/ping")["status"])`, { host });
syn.run(`require llm\nprint(reason about "el clima")\nprint(llm_usage())`, { host });
syn.run(`require llm\nprint(reason about "x")`, { host, ceiling: "stdout" });   // denegado: el techo manda

read-side RPC de blockchain (eth_balance, solana_, algorand_, btc_*). Se llama después del gate net(host), con la misma canonización de URL que el binario nativo. req es {method, url, headers, body, timeout} (body_base64 si es binario).

(remember/recall/forget_memory, reglas, progress) y state_. La memoria vive bajo el namespace memory:<nombre declarado> (el nombre declarado es la identidad, como en el .db nativo); state_ bajo state. memory_summary() reporta Backend: host-kv; recall busca por substring/tags, como el store en memoria nativo.

llm_available() pasa a true y llm_usage() suma los tokens que reportás.

confirmación RPC. Un Worker bloquea con Atomics.wait; el main thread de un navegador no puede.

Sin el hook, el builtin falla con la verdad: fetch: … this host provides no http transport (wasm profile) — the embedder can offer one through the http host hook, or run the program with the native synsema binary; memory "agenda" is declared but this host provides no durable storage; las ops LLM caen a los placeholders offline del core.

Hosts asíncronos (fetch del navegador, IndexedDB, SDKs de LLM)§

El intérprete es síncrono. runAsync/testAsync/handleAsync lo corren en un Worker y bloquean con Atomics.wait sobre un SharedArrayBuffer mientras tus Promises resuelven en el main thread — las respuestas más grandes que el buffer viajan en chunks. Node/Bun/ Deno funcionan sin más; el navegador necesita cross-origin isolation (Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp). Sin eso, usá la API síncrona con hooks síncronos.

const r = await syn.runAsync(programa, {
  host: { async http(req) { const res = await fetch(req.url, { method: req.method }); return { status: res.status, headers: res.headers, body: await res.text() }; } },
});
await syn.close();

serve sin sockets: modo handler para edge§

Cloudflare Workers, Fastly Compute, Fermyon Spin, Vercel Edge cargan un .wasm y llaman un handler por request. handle(source, request) es ese handler: tu plataforma le pasa el request y recibe la respuesta.

const app = `require serve(8080)
serve on 8080
    auth with check_token
    errors with shape_error
    route "GET /hola/:nombre"
        give {"hola": params.nombre, "visitas": state_incr("visitas")}
    route "POST /items" requires auth
        give created({"por": request.user.id, "body": request.json})
    route "GET /doc/:id"
        give content(page([heading(1, "Doc"), prose(params.id)], {"title": "Doc"}))`;

const res = syn.handle(app, { method: "GET", path: "/hola/ana?x=1", headers: { accept: "application/json" }, body: "" }, { host });
res.status; res.content_type; res.headers; res.body;   // res.log = las líneas de print del handler

El programa se prepara una vez por instancia (parse, top-level, tabla de rutas) y se reusa entre requests — sólo cambia el request. Rutas por especificidad, :param/resto, query, request (json, form, cookies, user), auth with (token, o token + request), errors with, 404/405 con Allow, expect → 400, content() negociado por sufijo o Accept, redirect, with_header/set_cookie, paginación de colecciones y state_ durable a través de tu kv funcionan como en el server nativo — incluidas las dos reglas que lo mantienen como el mismo lenguaje: require serve(puerto) sigue siendo obligatorio (el manifiesto, no el socket), y cada request corre sobre un snapshot de los globales (un set sobre un global dentro de un handler no persiste al request siguiente; el estado compartido va por state_*). No están en modo handler (la plataforma los hace antes de llamarte): stream (SSE) y proxy to responden 501, rate limits y static se ignoran (el mount deja un aviso en el log), los bloques host (vhosts) y el mount de grupos de rutas exportados se rechazan con un error claro, y TLS/ACME terminan en el host.

Tiempo, azar, archivos, tamaño§

now() sale del reloj del host (Date.now(), time.time(), time.Now()); random(), token(), mnemonic_generate y cada nonce de firma salen de la entropía del host (crypto.getRandomValues, os.urandom, crypto/rand) — la doctrina criptográfica no cambia. No hay filesystem: read_file y compañía fallan diciéndolo. El artefacto pesa ~7,5 MB (2,6 MB gzip); CI impone un presupuesto de 5 MB gzip. Un error del programa es dato (errors[]); un trap (un panic del intérprete) descarta la instancia y el glue la recrea.

El mismo lenguaje, un entorno que otorga menos§

El perfil wasm no es un dialecto. Es el lenguaje completo en un entorno que otorga sólo lo que el host presta — exactamente lo que el modelo deny-by-default ya expresa. Incluido en los dos artefactos, byte a byte idéntico al binario nativo (CI diffea las sondas bajo wasmtime y a través de la API de embebido bajo Node en cada push): el lenguaje completo, tasks, tipos, match, try/recover, enums, módulos, templates; la torre numérica y arrays; texto, regex, JSON, CSV, estadística; charts y export PNG/PDF; hashing, HMAC, secret; todo el lado puro de blockchain (eth_address, ABI, EIP-191/712, tx_eip1559, encoding Solana/Algorand, builder/PSBT de Bitcoin, *_sign gateados, HD wallets); el lado puro de web-auth (password hashing, JWT, TOTP, oidc_verify con JWKS inline); sandbox, intent, scoping de capabilities por tool, el techo del host; los helpers de respuesta y el vocabulario content(); multi-agente (agent/spawn/share/ observe/signal/wait_for) in-process; parallel_map/chunk secuencial (mismo orden y semántica fail-fast, sin pool de threads).

También en los dos: args() (vacío en el navegador; el argv después del archivo en wasip1) y — nuevo en v0.6.14 — este mismo perfil pure es un switch también en el binario nativo (synsema run --profile pure), así podés correr bajo el muro sin wasm en absoluto. Ver Sandbox § el perfil pure.

En ninguno de los dos artefactos — los nombres existen y fallan con la verdad del entorno, nunca con Undefined variable. El mensaje es <name>: not available in the pure profile — <why> (<hint>), idéntico a synsema run --profile pure (la pista pure nativa dice drop --profile pure to run it natively en vez de run the program with the native synsema binary):

FamiliaBuiltins<why>
Filesystem, execread_file/write_file/append_file/edit_file/list_dir/file_info/file_exists/grep, runthis run has no filesystem / … no child processes (build de navegador; wasip1 tiene archivos vía --dir)
WebSocket, identidad TLS, hub, entrega de Web Pushws_, mtls_identity, push_send (v0.6.15+ — push_vapid_keys es puro y queda), select/proc_/watch/term_this run has no raw network sockets (WebSocket/TLS identity need an event loop and a process) / … no I/O hub
Bases de datosdb_open/db_close, sql/sql_exec/sql_batch/sql_tables/paged, mongo_, redis_this run has no database drivers (en edge, llegá a D1/Neon/Upstash por http)
Croncron_*this run has no scheduler threads (el host agenda; el job se invoca)
Proceso (solo wasm)self_path, run_programthis run has no process / … no child processes
Threads reales (solo wasm)spawn, parallel_map, bus_*, agentsconservan su semántica, corren in-process / secuencial

(term_open es la excepción: devuelve nothing, como una corrida nativa sin TTY, así el fallback a read_line es el mismo programa. Bajo --profile pure nativo las familias de threads/procesos se mantienen — un proceso nativo tiene threads — así que solo filesystem/exec/sockets/db/cron quedan amuralladas, y fetch/http_* con net siguen llegando a la red.)

Compilar los artefactos desde el código§

Sólo hace falta para trabajar sobre el motor — los usuarios toman el .wasm del release o de npm.

rustup target add wasm32-wasip1 wasm32-unknown-unknown
cargo build --manifest-path engine/Cargo.toml -p synsema-wasm --target wasm32-wasip1 --profile wasm
cargo build --manifest-path engine/Cargo.toml -p synsema-wasm-web --target wasm32-unknown-unknown --profile wasm
# engine/target/wasm32-wasip1/wasm/synsema-wasm.wasm (~6,7 MB)
# engine/target/wasm32-unknown-unknown/wasm/synsema_wasm_web.wasm (~7,5 MB, 2,6 MB gzip)

synsema-stdlib tiene una feature native (activa por defecto) que gatea los módulos que hablan con el SO; sin ella el perfil puro compila a wasm32. El CI del motor chequea ese perfil, compila los dos artefactos, diffea las sondas puras contra el binario nativo y maneja el embebible desde Node, Python y Go en cada push.