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.com/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:

# run a program (wasmtime)
wasmtime run --dir . synsema-wasm.wasm program.syn

# run a file's `test` blocks
wasmtime run --dir . synsema-wasm.wasm --test program.syn

# read the program from stdin; pass config/secrets as env
wasmtime run --env ETH_KEY=... synsema-wasm.wasm -   < program.syn

# host ceiling — the SAME flags and parser as `synsema run`
wasmtime run --dir . synsema-wasm.wasm --sandbox program.syn                    # ceiling = [stdout, time]
wasmtime run --dir . synsema-wasm.wasm --cap-set stdout,secret=ETH_* program.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 summary be {"total": sum([120, 180, 95])}
let body be json_encode(summary)
let digest be decode(keccak256(body), "hex")
let addr be evm_address(secret("ETH_KEY"))
print(`summary={body}`)
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";

// Node (a script, Next.js routes and Server Actions): the synsema.wasm inside the package
const syn = await Synsema.loadNode();
// Browser: serve synsema.wasm with your static files and load it by URL
//   const syn = await Synsema.load("/synsema.wasm");
await syn.ready();

const r = syn.run(`print(keccak256("hola"))`, { env: { KEY: "abc" }, ceiling: "sandbox" });
r.output;   // ["…"]  the program's print lines
r.errors;   // []     parse/runtime errors are data, never exceptions
r.audit;    // every capability check, granted or not — see the fields below

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*. El motor lee el log antes de agregar la línea (v0.6.46): si esa lectura falla, la operación se niega y el log queda como estaba. 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, steps, exit} (steps = sentencias ejecutadas, v0.6.20+; exit = el código de exit(code), si no 0 o 1; con labels también declassify y private_seen, y con result el campo result — ver Código no confiable); 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".

Cargar el módulo. En Node, Synsema.loadNode() lee el synsema.wasm que trae el paquete (al lado de index.mjs, o de node_modules/@synsema/wasm cuando un bundler movió el código, como hace Next.js en las rutas y las Server Actions); si no lo encuentra en ninguno de los dos, tira un error con las rutas que probó. En cualquier otro lado, Synsema.load(src) recibe el módulo de alguna de estas formas: un WebAssembly.Module (para plataformas que te dan uno al importar el .wasm), sus bytes (Uint8Array/ArrayBuffer), un Response o una URL para bajarlo. En el navegador, copiá node_modules/@synsema/wasm/synsema.wasm a la carpeta que tu sitio sirve como estáticos y llamá Synsema.load("/synsema.wasm"). Bajo Node, un string que no empieza con http:/https: es una ruta de archivo, y una URL file: también se lee del disco. No escribas new URL("@synsema/wasm/synsema.wasm", import.meta.url): resuelve relativo al módulo actual, no al paquete. 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)),
    // compare-and-swap: write v only if the current value is exactly `expected` (null = missing)
    cas: (ns, k, expected, v) => {
      if ((store.get(ns + "/" + k) ?? null) !== expected) return false;
      store.set(ns + "/" + k, v);
      return true;
    },
  },
  llm: (op, prompt, info) => ({ content: "…", tokens: 12 }),   // plug your SDK here; info.principals (v0.6.46)
  log: (line) => console.log(line),
};

syn.run(`require memory("agenda")\nremember("preference", "dark mode", ["ui"])`, { host, filename: "agenda.syn" });
syn.run(`require memory("agenda")\nprint(recall(search="dark")[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 "the weather")\nprint(llm_usage())`, { host });
syn.run(`require llm\nprint(reason about "x")`, { host, ceiling: "stdout" });   // denied: the ceiling wins

read-side RPC de blockchain (evm_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) y, desde v0.6.46, redirect: "error" cuando la request lleva un secret. En wasm el motor chequea el nombre del host, no la dirección: que un programa no llegue a direcciones privadas es tarea del hook — ver El hook http.

(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. get devuelve el texto guardado o null, list un array con los nombres de las claves; desde v0.6.46 cualquier otra cosa, o un hook que lanza, es un error del builtin, no "la clave no está" — ver Qué tiene que contestar un hook.

value sólo si el texto guardado es exactamente expected (null = la clave no existe) y devuelve true o false. Los valores son el texto JSON que guarda el motor. Hace atómicos a state_merge y state_cas aunque varias instancias compartan tu store; tu store tiene que hacer la comparación y la escritura en un solo paso (una transacción, un put condicional). Un cas que devuelve otra cosa que un booleano (un return olvidado es undefined) hace fallar la llamada con kv.cas must return true or false, got ….

reason/decide/analyze/generate; llm_available() pasa a true y llm_usage() suma los tokens que reportás. info.principals (v0.6.46) lista los dueños de los datos privados que lleva el prompt — [] con datos públicos; un dato privado sólo llega acá cuando el ceiling lo permite (llm=<principal>@embedder, más abajo). Un hook que lanza hace fallar esa llamada con su mensaje (v0.6.46; antes, una respuesta vacía).

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.

state_merge y state_cas en wasm. Acá también existen (state_merge(key, map) mezcla el mapa en el guardado y devuelve el resultado; state_cas(key, expected, new) escribe sólo si el valor actual es igual a expected, con nothing para una clave que no existe, y devuelve si escribió). Qué tan atómicos son depende de lo que prestes:

motor reintenta el comparar-y-cambiar cuando otro escritor se le adelantó);

state_merge: the host's kv does not offer cas (kv.cas), so this update would not be atomic across instances; offer kv.cas, or use state_set if one writer is enough;

llamada por vez). Dura entre los requests de handle de esa instancia; cada run arranca con el estado vacío.

Qué tiene que contestar un hook (motor v0.6.46+)§

Hasta v0.6.45 un kv que fallaba, o que contestaba algo inesperado, se leía como "la clave no está", y la escritura siguiente pisaba lo que tenía tu store. Desde v0.6.46 cada uno de estos casos es un error del builtin que preguntó, con el motivo, y no se escribe nada encima de tus datos:

Lo que hace tu hookLo que recibe el programa
kv.get lanza (o su Promise se rechaza, en la API asíncrona)state_get: host kv.get failed: kv down — el mensaje del hook; un valor lanzado sin mensaje da su texto (… failed: 42)
kv.get devuelve un número, un booleano o un objeto (un valor ya parseado)state_get: host kv.get failed: kv.get must return a string or null, got a number (a boolean, an object)
el texto guardado no es JSON (cortado a la mitad, escrito por otra cosa)state_incr: the stored value of 'c' is not JSON (expected value at line 1 column 1); lo mismo en state_get, state_all, state_merge y state_cas, y el texto guardado queda como estaba (antes, state_incr sobre él daba 1)
kv.list lanzastate_all: host kv.list failed: <el mensaje>
kv.list devuelve otra forma: la {keys: [{name}]} de Cloudflare KV, una lista de {name}, una lista con algo que no es texto, undefinedstate_all: host kv.list failed: kv.list must return a list of strings, got an object (got a list with other values, got nothing); antes, state_all() daba {}
la carga de una memory declarada falla, o la memoria o el progress guardados no se pueden leerla memoria no se abre: todos los builtins de memoria de esa corrida fallan con memory "agenda" could not be loaded (the host's kv.get failed: …); nothing was loaded, so nothing is written over it (o (the stored memory is not readable (…))). Antes se abría vacía y el primer remember la pisaba
falla la lectura del log de auditoría de reveal, sign, wallet o spendla operación se niega y el log queda como estaba: reveal("K") failed: cannot write the audit log (reading the audit log failed: …). Refusing to reveal without an audit trail. Antes, el log se reemplazaba por una sola línea y la operación seguía
llm lanzaesa llamada falla con el mensaje del hook (reason: provider 503); un throw sin mensaje da reason: the host's llm hook threw. Si el prompt llevaba datos privados, el error se redacta como cualquier error que causen (private(a)). Antes, el programa recibía una respuesta vacía con ok: true

Lo que no cambió: kv.get que devuelve null o undefined es "la clave no está"; un kv sin list le da a state_all() un mapa vacío (ofrecé list si un programa lee el estado entero); un hook llm que no devuelve nada da una respuesta vacía; y un hook http que lanza o contesta {error} le da al programa el mapa {error: <mensaje>}, que lee como cualquier error de transporte.

Cómo migrar — breaking para un host cuyo kv.get devuelve números u objetos, o cuyo kv.list devuelve otra forma. Guardá el texto que el motor le pasa a set y devolvé ese mismo texto (es JSON: no lo parsees ni lo guardes como objeto), y hacé que list devuelva los nombres de las claves como un array de textos. Un store cuyo list contesta {keys: [{name}]}, como Cloudflare KV, necesita un map (hooks asíncronos: usá runAsync/handleAsync):

// `store` answers get(key) → text | null, put(key, text), delete(key), list({prefix}) → {keys: [{name}]}
const kv = {
  get: (ns, k) => store.get(`${ns}/${k}`),
  set: (ns, k, v) => store.put(`${ns}/${k}`, v),
  delete: (ns, k) => store.delete(`${ns}/${k}`),
  list: async (ns) => (await store.list({ prefix: `${ns}/` })).keys.map((x) => x.name.slice(ns.length + 1)),
};

Embebedores en Rust (HostProvider de synsema-stdlib, el trait que implementa el glue de wasm): kv_get ahora devuelve Option<Result<Option<String>, String>> (None = no se ofrece kv, Some(Ok(None)) = la clave no está, Some(Err(msg)) = la lectura falló) y kv_list Option<Result<Vec<String>, String>>; llm recibe un tercer argumento, principals: &[String], y devuelve Option<Result<HostLlmResult, String>> (Err = la llamada falla con ese texto); y HostHttpRequest tiene forbid_redirects: bool (el redirect: "error" de abajo).

Por la ABI cruda (el glue de Python y Go, o el tuyo): el host contesta kv_get con {"value": <texto o null>} o {"error": "<mensaje>"}, y kv_list con {"keys": [<textos>]} o {"error": …} (una respuesta sin keys ahora es el error got nothing). El payload de llm es {op, prompt, principals} y la respuesta un texto, {"content", "tokens"} o {"error": …} (un error vacío o null da the host's llm hook threw). El payload de http lleva "redirect": "error" cuando la request tiene un secret.

El hook http: redirects y direcciones privadas (motor v0.6.46+)§

En nativo la conexión la abre el motor. En wasm la abre tu hook, así que dos protecciones del cliente nativo existen sólo si el hook las mantiene.

redirect: "error". Una request que lleva un secret — en la URL, la query, el body, un form o cualquier header — llega al hook con req.redirect === "error"; una sin secret no trae el campo redirect (una request con un secret no deja la marca puesta para la siguiente). Respetalo: un redirect llevaría la clave a un host que el programa nunca nombró (un redirect a otro origen en fetch quita Authorization, pero no X-Api-Key ni ningún otro header), y el runtime nativo tampoco sigue redirects. Con fetch es una opción, y entonces fetch falla ante un 3xx:

async http(req) {
  try {
    const res = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body ?? undefined, redirect: req.redirect ?? "follow" });
    return { status: res.status, headers: res.headers, body: await res.text() };
  } catch (e) {
    return { error: `${e.message}${e.cause ? ": " + e.cause.message : ""}` };   // "fetch failed: unexpected redirect"
  }
}

Direcciones privadas. En nativo, desde v0.6.46 un grant de net con comodín (require net, net(""), net(".x.com"), un techo net) llega sólo a direcciones públicas: el cliente resuelve el nombre una vez, chequea la dirección y conecta a ésa. En wasm el motor nunca ve la dirección — chequea el nombre contra el grant y le pasa la URL a tu hook—, así que un programa con require net llega a tu hook con http://169.254.169.254/… o http://127.0.0.1.nip.io/. Si los programas no son tuyos, el hook tiene que resolver el nombre, chequear la dirección a la que va a conectar contra estos rangos y conectar a esa dirección (sin volver a resolver):

El hook no sabe si el programa nombró el host (require net("db"), que en nativo sí llega a una dirección privada), así que si tus programas necesitan un destino privado, nombralo en el hook. En Node, lookup en node:http/node:https resuelve, chequea y conecta en un solo paso:

import { lookup } from "node:dns";
import { BlockList, isIP } from "node:net";
import http from "node:http";
import https from "node:https";

// The ranges a wildcard `net` grant must not reach (the list in `Host.http`).
const blocked = new BlockList();
for (const [a, bits] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
  ["172.16.0.0", 12], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
  ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]]) blocked.addSubnet(a, bits, "ipv4");
// NAT64 (64:ff9b::/96) is blocked whole; to let public ones through, check its last 32 bits as an IPv4.
for (const [a, bits] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8], ["2001:db8::", 32], ["64:ff9b::", 96]])
  blocked.addSubnet(a, bits, "ipv6");
// Private destinations your own programs need: the hook cannot see `require net("db")`, so name them here.
const allowed = new Set(["10.0.0.5"]);
// BlockList checks an IPv4 inside an IPv6 (::ffff:a.b.c.d) against the IPv4 ranges.
const isBlocked = (ip) => !allowed.has(ip) && blocked.check(ip, isIP(ip) === 6 ? "ipv6" : "ipv4");

// Resolve once, check every answer, and connect to the checked address (no second lookup).
function safeLookup(name, opts, cb) {
  lookup(name, { ...opts, all: true }, (err, addrs) => {
    if (err) return cb(err);
    const bad = addrs.find((a) => isBlocked(a.address));
    if (bad) return cb(new Error(`${name} resolved to ${bad.address}, a private address`));
    opts.all ? cb(null, addrs) : cb(null, addrs[0].address, addrs[0].family);
  });
}

export function httpHook(req) {
  return new Promise((resolve) => {
    const u = new URL(req.url);
    const literal = u.hostname.replace(/^\[|\]$/g, "");
    if (isIP(literal) && isBlocked(literal)) return resolve({ error: `${literal} is a private address` });
    const lib = u.protocol === "https:" ? https : http;
    const opts = { method: req.method, headers: Object.fromEntries(req.headers), lookup: safeLookup, timeout: req.timeout * 1000 };
    const r = lib.request(u, opts, (res) => {
      // `redirect: "error"`: the request carries a secret; a 3xx is not followed (node:http never follows).
      if (req.redirect === "error" && res.statusCode >= 300 && res.statusCode < 400) {
        res.resume();
        return resolve({ error: `redirect refused: the request carries a secret (${res.statusCode})` });
      }
      const chunks = [];
      res.on("data", (c) => chunks.push(c));
      res.on("end", () => resolve({ status: res.statusCode, headers: Object.entries(res.headers).map(([k, v]) => [k, String(v)]), body: Buffer.concat(chunks).toString("utf8") }));
    });
    r.on("timeout", () => r.destroy(new Error("timeout")));
    r.on("error", (e) => resolve({ error: e.message }));
    const body = req.body_base64 != null ? Buffer.from(req.body_base64, "base64") : req.body;
    if (body != null) r.write(body);
    r.end();
  });
}

Con este hook, syn.runAsync(programa, { host: { http: httpHook } }) le da a un programa con require net un 200 para https://example.com/, y {error} para el resto: 127.0.0.1.nip.io resolved to 127.0.0.1, a private address, 169.254.169.254 is a private address, ::ffff:a00:6 is a private address ([::ffff:10.0.0.6] como lo escribe el parser de URL). Lo que contesta el hook es lo que el programa lee en r["error"]. En un navegador el hook no ve la dirección (fetch resuelve el nombre), así que una página depende de la red que la rodea; respetá redirect ahí también.

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(program, {
  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();

Las llamadas asíncronas aceptan las mismas opciones que run (env, ceiling, host, labels, result, input, maxSteps, maxMemoryMb, trusted, trustedCeiling) más timeoutMs, que sólo tiene la API asíncrona. Esperan en una sola cola y cada una va al primer Worker libre; Synsema.load(src, { workers: n }) (o loadNode({ workers: n })) le da a la instancia hasta n Workers (por defecto 1), así una ráfaga de llamadas corre de a n. close() termina los Workers y es final: lo que espera en la cola falla con synsema: closed (close() is final; load a new instance), y lo mismo cualquier llamada asíncrona posterior; la API síncrona sigue andando. Un Worker que no arranca dentro de startMs (opción de load/loadNode, por defecto 30 000 ms, entre 1 y 2147483647) se termina.

Código no confiable: etiquetas, límites, concurrencia§

Código que escribió un modelo es la razón habitual para embeber Synsema en un server. Estas opciones hacen seguro correrlo ahí:

const syn = await Synsema.loadNode({ workers: 4 });

const r = await syn.runAsync(code, {
  host,
  labels: true,                                                 // information-flow labels on
  input: { var: "customer", value: row, sources: { card: ["customer"] } },
  result: "out",                                                // r.result = the final value of `out`
  timeoutMs: 2000,                                              // async only
  maxSteps: 5_000_000,                                          // both modes
  maxMemoryMb: 64,                                              // both modes
});

Etiquetas. labels: true enciende las etiquetas de flujo de información en esa llamada (lo que hace synsema run --labels en nativo). input liga una variable global antes de correr: var es su nombre, value cualquier valor JSON, y cada clave del valor que figura en sources le llega al programa como private(valor, principales) (acá customer.card es privado de customer y customer.name es público). sources exige labels: true; sin eso la llamada falla con input: sources need labels: true …. Un valor privado, o una rama decidida por uno, se rechaza en cada hook del host, igual que el binario nativo lo rechaza en sus sumideros:

La llamada termina con ok: false antes de que corra el hook, así tu hook nunca ve el valor; el detalle (label_violation: fetch(argument 0) is private to customer, the sink accepts (public); declassify(…) …) está en operator_detail, no en errors (ver Qué muestra un corte abajo). print redacta un valor privado (private(customer)) en vez de fallar. Con labels cambian tres cosas más:

dio dependió del dato), y private_seen es true;

entradas hubo diría para qué lado fue la rama);

ahí, son label_violation, como en nativo.

result: "out" devuelve el valor final de la global out en r.result, con sus etiquetas: una parte privada vuelve como {"$private": ["customer"], "value": …}, así tu código decide qué hacer con ella. declassify lista cada declassify que hizo la corrida. result también anda sin labels (entonces es JSON común).

Qué muestra un corte (motor v0.6.47+). Un corte significa que la corrida paró donde decidió un valor privado, así que el resultado mismo de la corrida no debe decir cuál sitio fue. Desde v0.6.47 todo corte de etiquetas devuelve el mismo texto fijo, y el detalle va a un campo que es sólo tuyo:

const r = syn.run(`require net("api.x.com")
let c be private("4111", "customer")
fetch("https://api.x.com/" + c)`, { labels: true, host });
r.ok;      // false
r.errors;  // ["Runtime error: label_violation: the flow checker stopped the run (private to customer); what failed, where and how to fix it go to the operator, not to the program"]
r.output;  // ["private(labels): the run was stopped by the flow checker; its output is withheld because the NUMBER of lines before the stop depends on private data"]
r.operator_detail;  // "<embedded>:3:6: label_violation: fetch(argument 0) is private to customer, the sink accepts (public); declassify(…) …"

dato del corte, nunca el sitio ni el valor. Lo que se imprimió antes del corte se retiene (cuántas líneas hubo puede depender del dato).

(<embedded>:línea:columna) y cómo arreglarlo: los textos citados en esta página y en Etiquetas. Es para el log propio del operador. No se lo devuelvas al modelo ni al cliente, ni se lo pases a código que corre sobre los mismos datos. No está cuando la corrida no se cortó. Tu hook log no lo recibe.

{"error": "label_violation: the flow checker stopped the run (private to customers); what failed, where and how to fix it go to the operator, not to the program", "status": 500}; log tiene la línea retenida, errors va vacío (es para errores al preparar el programa) y operator_detail tiene el detalle.

texto fijo, y el corte para toda la suite.

Para ver el detalle mientras desarrollás, leé operator_detail, o corré el mismo programa en nativo con synsema test --labels / synsema run --labels sin techo: ahí el error nombra el sitio. Antes de v0.6.47 errors traía el detalle.

Límites. maxSteps (pasos del intérprete, los deterministas que cuenta steps()) y maxMemoryMb (los bytes que reserva la corrida) andan en la API síncrona y en la asíncrona, y en handle se aplican a cada request. Al pasar cualquiera de los dos la corrida termina con ok: false y un error que nombra el límite — step limit reached: the program ran more than 10000 steps (maxSteps), memory limit reached: the run reserved more than maxMemoryMb (en handle, un 500) —, nunca un trap. Los dos tienen que ser enteros positivos: 0 es un error (max_steps must be a positive integer), no "sin límite". Ninguna llamada hereda los límites de la anterior: ponelos en cada llamada. maxMemoryMb cuenta los bytes pedidos desde que empieza la corrida, no lo que la instancia ya tenía; la memoria del módulo puede crecer hasta cerca del doble y no se devuelve. El costo en el build wasm: contar reservas es ~3–4 % en código que reserva mucho, sin límites; con límites, 1–3 % al reservar y 9–14 % en un loop de cómputo apretado. El binario nativo no compila nada de esto. Con labels: true, una corrida que pega contra maxMemoryMb reporta steps: null y private_seen: true (v0.6.46) haya tocado lo que haya tocado: si se quedó sin memoria puede depender de un valor privado, así que tratá ese resultado como sensible, igual que cualquier corrida con private_seen. La corrida cortada no devuelve output y su audit viene vacío.

Plazo (sólo asíncrono). timeoutMs cuenta desde que la llamada empieza a correr, no mientras espera en la cola. Al pasarlo, la Promise se rechaza con un error que dice que la llamada no terminó en esos ms, y su Worker se reemplaza, así el hilo del server nunca se bloquea. Un hook del host que ya está corriendo (un fetch) no se corta; su resultado se descarta. 0 es sin plazo; si no, un número finito de milisegundos hasta 2147483647 (Infinity es un error). Un hook del host sin plazo propio que nunca contesta ocupa su Worker si la llamada no tiene timeoutMs.

Fallas. Un Worker que muere hace fallar la llamada que estaba corriendo (no se reintenta: pudo haber tenido efectos) y se reemplaza. Un hook del host que devuelve algo que no es JSON (un BigInt, un objeto circular) o tira un valor sin mensaje cuenta como una falla de ese hook en esa llamada (para http, fetch devuelve su mapa {error}), en las dos APIs — no un Worker colgado ni un módulo con trap. handle guarda una app preparada distinta por valor de labels y por conjunto de hooks del host, así un request con otros hooks u otras etiquetas nunca reusa una app preparada para otros.

Techos para corridas con etiquetas, tools de confianza, recibos sellados (motor v0.6.46+)§

declassify bajo un ceiling. Con labels: true y un ceiling, declassify es una capability que el techo acota: sólo se pueden publicar los principales que nombra.

const code = `let c be private({"name": "Ana", "card": "4111"}, "customers")
print(declassify(c.name, "the name is public"))`;
syn.run(code, { labels: true });                                          // no ceiling: allowed, as before
syn.run(code, { labels: true, ceiling: "stdout" });                       // refused: the ceiling names no principal
syn.run(code, { labels: true, ceiling: "stdout,declassify=customers" });  // allowed
syn.run(code, { labels: true, ceiling: "stdout,declassify=cust*" });      // allowed: globs work
syn.run(code, { labels: true, ceiling: "sandbox" });                      // refused: "sandbox" never grants it

La negativa (en operator_detail; errors trae el texto fijo) es `label_violation: declassify is not granted for what this value is private to (customers). With a ceiling the host names what a program may publish (--cap-set declassify=<principal>), and inside a sandbox only a sandbox under that delegates declassify allows it; nombra la etiqueta declarada, try/recover no la atrapa y la corrida termina con ok: false. Un declassify pelado en el techo permite cualquier principal. Sin labels no se chequea (declassify es la identidad), y sin techo no cambia nada. Breaking: un host que corre código con etiquetas bajo un ceiling y espera que publique tiene que agregar declassify=<principal> a ese techo. En el programa, require declassify("customers") nombra lo que publica, y un require declassify pelado deja (por tu hook log) warning: bare require declassify permits publishing data private to ANY principal; scope it with require declassify("principal") (the principal the call removes)`.

Datos privados a tu hook llm: llm=<principal>@embedder. Con etiquetas, un valor privado en reason/analyze/decide/generate llega al hook llm sólo cuando el programa declara de quién son los datos que manda y el techo permite ese principal para el embebedor:

const seen = [];
const host = { llm: (op, prompt, info) => { seen.push(info.principals); return "an answer"; } };
const code = `require llm("customers")
let note be private("the customer's note", "customers")
print(label_of(reason note))`;
syn.run(code, { host, labels: true, ceiling: "stdout,llm=customers@embedder" });  // ok: prints [private(customers)]
// seen → [["customers"]]: info.principals = the owners of the private data in the prompt ([] for public data)

llm=customers@* permite cualquier proveedor, a propósito. llm=customers@local (un modelo dentro de un proceso nativo), un ítem sin proveedor (llm=customers, llm), ningún techo, o un require llm pelado en el programa, todos lo niegan antes de que corra el hook (esto es el operator_detail): `label_violation: reason would send data private to (customers) to the model; the host allows that per principal and provider in its ceiling (--cap-set llm=<principal>@<provider host>, @local for a model in the process, @embedder in wasm) and the program declares require llm("<principal>"). …. Cuando se permite, el prompt lleva el dato mismo, la respuesta vuelve privada con la etiqueta de lo que entró, y audit recibe una entrada llm("customers@embedder") con source llm-data. Las llamadas con datos públicos siguen andando bajo cualquiera de estos techos, con info.principals vacío. judge funciona igual con require judge("customers") y judge=customers@embedder (negado: label_violation: judge would send data private to (customers) to the model; …`). Lo que esto no da: tu proveedor ve los datos (permití sólo un proveedor en el que confiás), y lo que contesta el modelo es privado pero no confiable.

judge se gatea en wasm. Un bloque judge bajo un ceiling sin judge falla como en nativo: Capability not granted: judge — declared but above the host ceiling (--sandbox/--cap-set). The program cannot fix this; the host must widen the ceiling. Antes de v0.6.46 corría igual.

Tools de confianza: trusted y trustedCeiling. Tu app puede prestar un módulo propio que el código en el que no confía sólo puede llamar. trusted: { nombre: fuente } lo hace importable como use "trusted:nombre" as t; cada task exportada corre con sus propios require, acotados por trustedCeiling (la gramática de ceiling) cuando lo pasás, y nada del que llama — ni sus grants, ni su ceiling:

const trusted = { tools: `export task first_name(c)
    require declassify("customers")
    give declassify(c.name, "the customer's own name")
` };
const code = `use "trusted:tools" as t
let c be private({"name": "Ana", "card": "4111"}, "customers")
print(t.first_name(c))`;
syn.run(code, { labels: true, ceiling: "stdout", trusted, trustedCeiling: "declassify=customers" });  // prints Ana

El mismo programa llamando a declassify él mismo bajo ceiling: "stdout" se niega. Sin trustedCeiling una task de confianza desclasifica como un programa sin techo (cualquier principal), así que dale uno; un trustedCeiling que no nombra el principal la niega con el mismo label_violation, y uno sin net niega su require net(…) (declared but above the host ceiling). Errores: un import sin ese módulo, `no trusted module named 't': the host loads it (synsema run --trusted t=./file.syn, or trusted: { t: source } in wasm); a program cannot load one itself; una task o una lambda entre los argumentos, trusted:t.f: 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; un valor que no es texto, trusted.t: expected the module source as text; un error de parseo en el módulo, trusted:t:1:9: …; un trustedCeiling mal escrito, trusted_ceiling: --cap-set: unknown capability 'nett'. Known: …. Un módulo de confianza ve los builtins y sus propios nombres, nunca los del programa (un programa que define su propio upper no cambia lo que corre la tool), y set t.x to … cambia sólo la copia del programa. Anda en run, test, handle y sus formas *Async. Todo lo demás de las tools de confianza — lo que todavía no pueden hacer, el --trusted` nativo — está en Etiquetas.

Recibos sellados: auditor. El motor acepta "auditor": "<la pública X25519 del auditor, en base64url>" (de synsema receipt keygen) y opcionalmente "sealed_bytes": n (el tamaño del sobre, de 4096 a 16777216, default 65536) en un request run, y devuelve el recibo sellado en receipt, antes de ok. Con un auditor, una operación auditada en una rama privada (reveal, sign, operaciones de wallet) corre, y su línea de auditoría va al sobre, no a tu kv; spend se sigue negando ahí. En @synsema/wasm v0.6.46 y v0.6.47, run()/runAsync() no reenvían auditor (no está en RunOptions y el resultado no trae receipt); hasta que el paquete lo haga, llamá al módulo por la API de bajo nivel, cuyo call toma el request del motor tal cual:

import { Synsema, instantiateSync, dispatchHostSync, offersOf } from "@synsema/wasm";
const syn = await Synsema.loadNode();
const host = { kv: { get: (ns, k) => null, set: (ns, k, v) => {} } };
const inst = instantiateSync(syn.module, (kind, payload) => dispatchHostSync(host, kind, payload));
const r = inst.call({ op: "run", source: code, filename: "app.syn", labels: true, auditor: AUDITOR_PUBLIC_KEY, host: offersOf(host) });
r.receipt;   // the receipt object: write it as one JSON line for `synsema receipt verify` / `open`

synsema receipt verify receipt.json lo chequea y synsema receipt open receipt.json --key @auditor.key lo abre, en nativo. Una clave mal formada es auditor: the auditor key is not base64url: …, un tamaño fuera de rango sealed_bytes must be between 4096 and 16777216, got 100. Sólo run devuelve un recibo (test y handle no). El recibo en sí — sus campos, lo que oculta y lo que no — está en Etiquetas.

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 /hello/:name"
        give {"hi": params.name, "visits": state_incr("visits")}
    route "POST /items" requires auth
        give created({"by": 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: "/hello/ana?x=1", headers: { accept: "application/json" }, body: "" }, { host });
res.status; res.content_type; res.headers; res.body;   // res.log = the handler's print lines

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_*). El linaje también es por request (v0.6.46): receipt() dentro de un handler lista en credentialSubject.inputs sólo lo que leyó ese request — lo que leyó el top-level mientras se preparaba la app (una llamada LLM, un fetch) ya no aparece en el recibo del primer request. 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()). El azar sale de la entropía del host (crypto.getRandomValues, os.urandom, crypto/rand), pedida una vez por llamada: cada llamada toma 32 bytes del host, que siembran el generador ChaCha propio de esa corrida, y random(), random_bytes(n), token() y el blinding de RSA sacan de ahí sin volver a pedirle al host — así cuánto azar usó una corrida (un n privado, por ejemplo) no se le nota al host. Con labels, now() devuelve el reloj leído al empezar la corrida y en cada interacción con el host (una llamada a un hook), así cuándo y cuántas veces el programa leyó el reloj tampoco se nota: dos now() con sólo cómputo en el medio devuelven la misma hora. 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: la API síncrona tira synsema: the module trapped (…); the instance was recreated y recrea la instancia sola, así la próxima llamada anda sin otro await syn.ready() (si la plataforma no deja crearla de forma síncrona, la próxima llamada pide ready()); en la API asíncrona el Worker recrea su propia instancia y la Promise de la llamada se rechaza con el mismo mensaje.

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 (evm_address, ABI, EIP-191/712, evm_tx/evm_tx_create, 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.

Dos diferencias llegan con v0.6.29. Parquet (parquet_read, parquet_write) no está en el build wasm. Y while conserva su tope de iteraciones ahí (1.000.000), que el engine nativo sacó en v0.6.29: un host wasm no tiene timeout, thread ni señal para frenar un loop que nunca termina, así que sin el tope se colgaría.

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.)

Guests — Synsema adentro del ABI de otro host§

Algunos hosts no cargan un comando WASI ni proveen los imports synsema_host: instancian un módulo una vez y llaman a sus propios exports con punteros a la memoria. Para esos, el motor queda como está y un adaptador fino vive en packages/guests/<host>/ — los exports del host mapeados sobre synsema_call, con tu .syn embebido para que un .wasm sea exactamente una app. El primero es Vela (el coprocesador confidencial de Horizen): packages/guests/vela compila un módulo wasm32-wasip1 que expone allocate/deallocate/load_module/deploy/deposit/process_request/trusted_request; tu programa define deploy(ctx), deposit(ctx), process(ctx) (y opcionalmente deanonymize/trusted/load_module), cada una recibe un mapa y devuelve un mapa — con payload_hex de entrada para payloads ABI de un trigger contract, <campo>_hex de salida para bytes exactos, subtipos como 0x… o etiquetas cortas; fuel es lo que declarás o los steps() deterministas; el programa corre bajo el techo stdout,declassify=app (sin tiempo, sin azar; desde v0.6.46 declassify lo acota el techo y app es el único principal del guest) porque Vela firma el state root. SYNSEMA_VELA_APP=mi_app.syn cargo build --profile wasm embebe el tuyo; la release publica synsema-vela-guest.wasm con el ledger de ejemplo. El cuadro completo — el contrato, tres apps de ejemplo (una compatible con el wallet novaw de Horizen), un cliente en Synsema para el otro lado del enclave (registro, deploy, requests cifrados, reportes, eventos, facilitador), trigger contracts, ERC-20 y la receta del starter kit local, todo verificado contra el stack real — está en la página de Vela. El patrón para el próximo host es "copiar, renombrar, mapear los exports".

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.