Agents & coordination
Observability
-- Doc example: observability. `log` is real; `trace`/`measure`/`checkpoint` are
-- decorative markers — they RUN their body but don't persist timing/snapshots.
intent: "doc example: observability"
require file.write("./_doctest_outputs/**") -- what the run writes lands in the receipt (v0.6.46+)
let answer be 0
measure "demo"
set answer to 41 + 1
print("measure ran → answer = " + text(answer))
test "measure runs its body (the timing instrumentation is decorative)"
let x be 0
measure "compute"
set x to 41 + 1
assert_eq(x, 42)
-- Lineage (v0.6.29+): the engine records every input the program reads. parquet_read is pure,
-- so it shows the shape with no capability: a file passed as bytes is recorded by its SIZE,
-- never its content, and the hash covers the rows as canonical JSON ("jcs").
test "lineage() lists each input with a hash anyone can recompute"
assert_eq(lineage(), [])
let rows be parquet_read(parquet_write([{"sku": "a-1", "qty": 3}]))
let e be lineage()[0]
assert_eq(e.source, "parquet_read")
assert(starts_with(e.what, "bytes "))
assert_eq(e.encoding, "jcs")
assert_eq("0x" + e.sha256, hex(sha256(canonical_json(rows))))
assert_eq(e.bytes, length(bytes(canonical_json(rows))))
-- Outputs (v0.6.46+): the receipt also names what the run WROTE, in order, with the sha256 of
-- the bytes that reached the disk. append_file records the appended chunk, edit_file the file
-- as it was left. The hash has no salt: anyone holding the file recomputes it.
test "receipt() lists each write in credentialSubject.outputs"
write_file("./_doctest_outputs/model.txt", "weights v1")
append_file("./_doctest_outputs/model.txt", " +bias")
let outs be receipt().credentialSubject.outputs
assert_eq(length(outs), 2)
assert_eq(outs[0].source, "write_file")
assert_eq(outs[0].what, "./_doctest_outputs/model.txt")
assert_eq(outs[0].encoding, "text")
assert_eq("0x" + outs[0].sha256, hex(sha256(bytes("weights v1", "utf8"))))
assert_eq(outs[1].source, "append_file")
assert_eq(outs[1].bytes, 6)
assert_eq("0x" + outs[1].sha256, hex(sha256(bytes(" +bias", "utf8"))))
test "a write is an output, not an input"
write_file("./_doctest_outputs/report.bin", bytes([1, 2, 3]))
let cs be receipt().credentialSubject
let last be cs.outputs[length(cs.outputs) - 1]
assert_eq(last.what, "./_doctest_outputs/report.bin")
assert_eq(last.encoding, "bytes")
assert_eq(last.bytes, 3)
assert_eq(length(where(cs.inputs, (e) => e.what == "./_doctest_outputs/report.bin")), 0)
assert_eq(get(cs, "outputs_truncated"), nothing)Logging (real)§
log takes a full expression and writes it to stdout as [LOG] …. Under synsema run, print, log and show are written as they happen, line by line and in program order (v0.6.29+; before, print was held until the program ended). Under serve, log/print reach the terminal with a [serve] prefix.
log "Processing order " + order_id
logis a statement, not a function — and not an expression.log "msg"works;log("msg")passescheckbut fails at run time withUndefined variable: 'log', andlet x be log "msg"does not parse.
Logs under serve (request logging)§
serve does not write an access log for you — by default the terminal is quiet, which makes development hard. Add a log in your handlers to see traffic live:
route "GET /:lang/:version/:slug"
log "GET " + (path of request)
give render_page(...)
Each request then prints in the server's terminal as [serve] [LOG] GET /…. print works there too (same [serve] prefix). (This docs site logs its own requests this way.)
Markers — trace / measure / checkpoint (decorative)§
trace and measure run their body but the timing instrumentation is currently a stub; checkpoint is a single line with no body. None of them persists anything. trace/measure take a literal name; checkpoint takes an expression (checkpoint "step_" + text(i)).
measure "db_query"
run_query(sql)
For real crash-resume / step tracking, use the progress builtins, not checkpoint — see Memory & state.
Capability audit (--audit json)§
Every capability check the runtime makes — granted or denied — can be streamed as JSON, one line per check, on run/test/conform/serve (engine v0.6.14+):
synsema run --audit json program.syn # to stderr
synsema run --audit ./audit.jsonl program.syn # to a file
synsema run --audit fd:3 program.syn # to a file descriptor (Unix only)
synsema run --audit unix:/run/audit.sock program.syn # to a Unix socket someone is listening on (Unix only; loud failure if nobody is, v0.6.20+)
SYNSEMA_AUDIT=json ./app # the same values from the environment — no flag; works in a synsema build binary (v0.6.20+)
Each line is {ts, context, capability, granted, source, reason, origin, file, line}:
context— which CapabilitySet (program,agent,request,worker, asandbox:/tool:frame).origin—"program"(arequireor a call the code made) or"runtime"(an ambient grant:stdout/time/llm, orservefrom--port).reason—Granted by <grant>,No matching grant found,above host ceiling (--sandbox/--cap-set),Explicitly denied by …,auto-granted by the runtime(an ambient grant that succeeded now leaves a trace), orbundled asset (part of the program)(asynsema buildbundle read).source— the builtin that triggered the check (read_file(),run(), …), orceiling/ambient/secret-builtin.file/line— where in the program (ornull).
A final line summarizes: {"summary": {"granted": N, "denied": M, "exit": code}}. Secret values never appear — capability scopes are names (secret("STRIPE_KEY")), never the value. This is the same audit the WebAssembly run() returns as r.audit (fields on the WASM page); the native stream just adds ts/context/file/line. run_program returns its child's audit as r["audit"] — a value, not a log to parse.
synsema build … --audit json|<path>|unix:<path> bakes the sink into the binary (engine v0.6.20+); SYNSEMA_AUDIT in the environment turns it on for any binary, baked or not, and the flag wins over the variable. On Windows fd:N and unix: are refused with exit 2 — use json or a path.
Step counter — steps() (engine v0.6.20+)§
steps() → the number of statements the interpreter has executed so far in this program. It counts nodes, not time, so the same program gives the same number on every run — a deterministic measure of work for metering, tests and fuel-style limits in a host that runs other people's programs. No capability (introspection, like llm_usage()), available in every profile. synsema run --format json reports the total as steps, next to llm_tokens.
Under labels (engine v0.6.24+, --labels / serve --attested — see Labels) the counter is not neutral: it is linear in what the program walked, so after a branch taken on a private value it is the secret. steps() therefore comes back carrying the union of what the run touched, and the host stops publishing it — run --format json and run --attest report "steps": null for such a run. declassify(steps(), "<why>") if you mean to publish it. Under serve the counter is per request, not per process: the server reuses the interpreter between requests and a running total would leak the previous caller's work.
let before be steps()
process(batch)
log "cost: " + text(steps() - before) + " steps"
Lineage — which inputs produced a result (v0.6.29+)§
The engine, not your program, records every piece of data the program reads from outside, in order: files and directory listings, grep, Parquet, stdin (read_line), HTTP, SQL/Mongo/Redis reads, recall, chain nodes (EVM, Solana, Algorand, Bitcoin), sockets and processes, what run/run_program return, and the answer of a real model to reason/analyze/generate. You cannot add, edit or drop an entry. lineage() lists them so far:
| Field | What it holds |
|---|---|
source | the builtin that read it (read_file, http_get, recall, parquet_read, read_line, llm, …) |
what | only what is safe to publish: a path the program passed as text, the host of a URL (never user:pass@, the path or the query), bytes <n> for a file passed as bytes, stdin, or a salted commitment query sha256-salted:<hex> for a SQL query, a Mongo filter, run arguments, an RPC call or a model prompt (reason prompt sha256-salted:…) |
sha256 | hex, without 0x, of the bytes the program received |
bytes | their length |
encoding | which bytes were hashed, so anyone recomputes it: "text" (UTF-8), "bytes", "jcs" (canonical_json(x)), "json" (json_encode(x), for an integer beyond 2⁵³ or a NaN) or "display" (the printed form) |
salt, committed_encoding | only on a committed entry, and only in the local lineage(): with them the owner can later reveal one query and anyone can check it — hex(sha256(bytes(salt, "hex") + bytes(canonical_json(args), "utf8"))) |
let rows be parquet_read(parquet_write([{"sku": "a-1", "qty": 3}]))
let e be lineage()[0]
print(e.source + " " + e.what) -- parquet_read bytes <file size>
print("0x" + e.sha256 == hex(sha256(canonical_json(rows)))) -- true
Nothing else is recorded as an input: not what the program writes (that is an output, below), not env/secret, not the blackboard, not an offline LLM placeholder, not a read that never arrived, not the file edit_file reads before it edits. receipt() publishes the same list as credentialSubject.inputs (without the salts), next to the program's sha, the engine, the capability audit, the outputs and the result you declare — signed, it proves which inputs, which program, which files written and which output. Receipts and their signatures: Agent identity.
The list stops at 10,000 entries. inputs_truncated: true appears in the receipt only when an entry was actually dropped (engine v0.6.46+; before, it was already true with exactly 10,000); lineage() stays at 10,000. Under serve, lineage() and the receipt's inputs are those of the request (engine v0.6.46+; before, they also listed what earlier requests of the same worker had read, which could be another user's).
Outputs — what the run wrote (engine v0.6.46+)§
receipt().credentialSubject.outputs lists every file the run wrote, in order, with the same fields as an input:
| Field | What it holds |
|---|---|
source | write_file, append_file, edit_file, zip_extract or tar_extract |
what | the path as the program wrote it ("./out/model.bin", not an absolute path); for an extraction, the destination as written plus the entry's name ("./out/zx/z/one.txt") |
sha256 | hex, without 0x, of the bytes that went to disk |
bytes | their length |
encoding | "text" (a text written as UTF-8) or "bytes" (bytes, and every extracted entry) |
write_filerecords the whole content;append_filerecords only the appended chunk;
edit_file records the file as it was left; zip_extract/tar_extract record one entry per file extracted.
- A trained model, a report or an export written to disk is in the signed receipt without hashing
it by hand: whoever holds the file recomputes sha256 and compares.
parallel_map: what each worker reads and writes is included, in the order of the items
(their inputs were missing before v0.6.46 too), also when an item fails — what the workers read and wrote until then stays.
- Under
serve,outputsare those of the request. - Same cap as
inputs: 10,000 entries, thenoutputs_truncated: true(the field is absent while
nothing was dropped).
- The
sha256has no salt, as forinputs: after adeclassify, the hash of a short value (a
PIN, a yes/no, a small number) can be guessed by hashing every candidate. Do not write a short secret to disk in a run whose receipt you publish.
- With labels on, a write under a private context is refused (writing is a public sink), so a
private branch never reaches outputs.
- An
append_filethat fails half-way can leave bytes on disk that are not listed. print/logare not outputs here: that is the program's output, whichrun --attesthashes.
require file.write("./out/**")
write_file("./out/model.txt", "weights v1")
append_file("./out/model.txt", " +bias")
let outs be receipt().credentialSubject.outputs
print(outs[0].source + " " + outs[0].what + " " + text(outs[0].bytes)) -- write_file ./out/model.txt 10
print(outs[1].source + " " + text(outs[1].bytes)) -- append_file 6
print("0x" + outs[0].sha256 == hex(sha256(bytes("weights v1", "utf8")))) -- true
With an auditor (next section), each write also goes into the sealed receipt as an entry with kind: "output", in the order it happened: for parallel_map workers, the order in which they were made, not the order of the items (the same as their capability entries). receipt() keeps the order of the items.
Sealed receipts for an auditor (engine v0.6.46+)§
With labels on, a builtin that writes an audit trail (reveal, sign and the signing builtins, the wallet operations) is refused in a branch decided by a private value: whether its audit line exists tells the operator which way the branch went. A sealed receipt lets it run: the host designates an auditor by an X25519 public key, and what the run leaves in that branch goes into an envelope only the auditor can open. Everyone else can still check that the receipt is whole and signed.
synsema receipt keygen --out auditor.key # the auditor, once
synsema run --labels --auditor <public> --receipt run.json program.syn # the host
synsema receipt verify run.json # anyone
synsema receipt open run.json --key @auditor.key > opened.json # the auditor
synsema run --labels --replay opened.json program.syn # the auditor, again
The auditor's keys — synsema receipt keygen§
synsema receipt keygenprints{"private": "<base64url>", "public": "<base64url>"}on stdout:
a fresh X25519 pair, 32 bytes each, base64url without padding.
synsema receipt keygen --out auditor.keywrites the private key to a new file and
prints only {"public": "…", "private_in": "auditor.key"} on stdout, plus a hint on stderr: open receipts with: synsema receipt open <receipt.json> --key @auditor.key. The file must not exist: synsema receipt keygen: cannot create auditor.key (it must not exist): <OS error> (exit 1); the same message when its folder does not exist.
- The public key is what the host gets; the private key never leaves the auditor.
Turning it on — --auditor / SYNSEMA_AUDITOR_KEY§
synsema run --auditor <public> and synsema serve --auditor <public>, or SYNSEMA_AUDITOR_KEY in the environment or in .env (the flag wins; synsema init lists it, commented, in .env.example). A binary made by synsema build reads SYNSEMA_AUDITOR_KEY too. In @synsema/wasm it is the auditor option: see WebAssembly. synsema test has no --auditor (and ignores the variable).
The key is checked before the program runs (exit 2):
| Message | Fix |
|---|---|
--auditor: the auditor key must be an X25519 public key of 32 bytes, got N | pass the public of receipt keygen (base64url, 43 characters); hex is not accepted |
--auditor: the auditor key is not base64url: invalid base64url: … | the same; this is also the message for a bad SYNSEMA_AUDITOR_KEY |
--auditor: the auditor key cannot seal (a low-order X25519 point, such as all zeros): ask the auditor for the public key of synsema receipt keygen | a degenerate key would seal to nobody; ask for a real one |
--receipt needs an auditor: pass --auditor <X25519 public key> or set SYNSEMA_AUDITOR_KEY | --receipt alone does nothing; add the key |
--auditor and --explain are mutually exclusive (the receipt goes before the output, and --explain prints a diagnostic) | drop --explain |
An auditor without --labels works too: nothing is private, so every entry is public, but the run still gets a signed log, a recorded seed and recorded clock readings for --replay.
The envelope size — SYNSEMA_RECEIPT_SEALED_BYTES§
The envelope is HPKE (RFC 9180: X25519, HKDF-SHA256, AES-128-GCM) of a plaintext of a fixed size: SYNSEMA_RECEIPT_SEALED_BYTES, 65536 by default, between 4096 and 16777216. So the receipt is as long after 0 sealed entries as after 50: its length says nothing about the branch. At the default a receipt is about 89 KB (the envelope is 32 + N + 16 bytes, in base64url); only the public part changes its length. Set by the host (environment or .env), never by the program.
SYNSEMA_RECEIPT_SEALED_BYTES must be between 4096 and 16777216, got N/ `… must be a number
of bytes, got "abc"` — exit 2 before the program runs.
- What does not fit is cut inside and counted: first the clock readings (they take at most a
quarter of the envelope anyway, see below), then the last entries. truncated in the opened receipt counts the sealed entries dropped; the chain covers the ones kept. If you see truncated above 0, raise the size.
The shape of a receipt§
One JSON line:
{"synsema_receipt": 1,
"header": {"v": 1, "suite": "HPKE-Base-X25519-SHA256-AES128GCM", "engine": "v0.6.46",
"program_sha": "<hex>", "config_sha": null, "started": "2026-10-23T07:36:04Z",
"run_key": {"alg": "Ed25519", "public": "<base64url>", "identity": "ephemeral"},
"auditor": "<base64url>", "seed_sha256": "<hex>", "public_entries": 8,
"sealed_bytes": 65536},
"public": [{"kind": "capability", "context": "program", "capability": "stdout", "granted": true, "…": "…"},
{"kind": "input", "source": "read_file", "what": "in.txt", "sha256": "<hex>", "bytes": 4, "encoding": "text"},
{"kind": "output", "source": "write_file", "what": "./out/a.txt", "sha256": "<hex>", "bytes": 3, "encoding": "text"}],
"sealed": "<base64url: enc ‖ ciphertext>",
"root": "<hex>",
"signature": "<base64url>"}
program_shais the same assynsema code shaandreceipt()give.config_shaisnull
except under run --attest and serve --attested, where it is the attested configuration's. started is UTC, to the second. seed_sha256 is sha256 of the seed's bytes (the seed itself is in the envelope). public_entries counts the public part.
publicholds the entries made with the program counter public, in run order:capability
(the capability audit line of --audit json, without ts), input (a lineage entry) and output. The sealed part holds what happened in a private branch: capability entries (with ts) and op entries, {"kind": "op", "line": "<the audit line>", "log": "reveal.log"}. A sealed operation's audit line goes into the envelope instead of ~/.synsema/audit/reveal.log (or sign.log, wallet.log): the log on disk would tell the operator what the envelope hides.
rootis the end of a hash chain over all the entries, public and sealed, in run order:
h0 = sha256("synsema-receipt-v1" ‖ jcs(header)), hi = sha256(hi-1 ‖ "P" or "S" ‖ jcs(entry)). jcs is canonical_json. The public part carries no positions (a sequence number would tell how many sealed entries came before), so only the auditor can recompute the chain.
signatureis the run key's signature over `"synsema-receipt-v1" ‖ sha256(jcs(header)) ‖
sha256(jcs(public)) ‖ sha256(sealed bytes) ‖ root bytes. The envelope is bound to the header too (its AAD is sha256(jcs(header))): change anything in the header, even only to re-sign it with another key, and the auditor's open fails with the envelope does not open with this key …`.
synsema receipt verify and synsema receipt open do these checks for you; this program does them by hand, which is what a verifier written in another language has to repeat:
require file.read("run.json")
require file.read("opened.json")
let r be json_decode(read_file("run.json"))
let o be json_decode(read_file("opened.json"))
let D be bytes("synsema-receipt-v1", "utf8")
let h be r["header"]
task digest(v)
give sha256(bytes(canonical_json(v), "utf8"))
-- anyone: the signature (an Ed25519 run key; a P-256 one is ECDSA, r ‖ s)
let msg be D + digest(h) + digest(r["public"]) + sha256(bytes(r["sealed"], "base64url")) + bytes(r["root"], "hex")
print(ed25519_verify(msg, bytes(r["signature"], "base64url"), bytes(h["run_key"]["public"], "base64url"))) -- true
-- the auditor: the chain, over the opened entries in run order
let x be sha256(D + bytes(canonical_json(h), "utf8"))
each e in o["entries"]
let tag be "P"
when e["sealed"]
set tag to "S"
set x to sha256(x + bytes(tag, "utf8") + bytes(canonical_json(e["entry"]), "utf8"))
print(slice(hex(x), 2) == r["root"]) -- true
Who signs it — run_key.identity§
identity | key | what it guarantees |
|---|---|---|
"identity" | the host's SYNSEMA_IDENTITY_KEY (Ed25519 seed, 64 hex; a bad value is SYNSEMA_IDENTITY_KEY must be the ed25519 seed as 64 hex chars, exit 2) | tamper evidence: only the holder of that key can make a receipt that verifies under its public key |
"attested" | under serve --attested, the attested P-256 key, the same one the attestation document names (public is the compressed point, 33 bytes; the signature is r ‖ s, 64 bytes) | tamper evidence bound to the measured code: see Attestation |
"ephemeral" | an Ed25519 key made for the run | only accidental damage: whoever can change the receipt can make a new one, envelope and signature included. For tamper evidence use SYNSEMA_IDENTITY_KEY, run --attest (which binds this key, see Attestation) or serve --attested |
When it comes out — before the output§
synsema runwrites the receipt to stderr, or to--receipt <path>, and only then
prints. With an auditor, print, flush() and the prompt of read_line do not go out live: the output comes after the receipt. eprint does go out as it runs (it is a sink, on stderr).
--receipt <path>replaces the file on every run. A path that cannot be written is
--receipt: cannot write <path>: <OS error> (exit 2) before the program runs, and a receipt already in that file stays until a run writes its own. A run that fails (a parse error, a runtime error) still writes its receipt, and exits 1.
--receipt fd:Nwrites to a descriptor the parent left open, and is Unix only. On Windows:
--receipt fd:3: a file descriptor is only available on Unix; pass a path (exit 2).
synsema run --format jsonputs the receipt first, asreceipt, beforeok,output(which
then also holds the read_line prompts) and steps; it also writes it to --receipt if given.
Under serve — one receipt per request§
synsema serve --auditor <public> [--receipt <path|fd:N>] writes one receipt per request that runs program code, before the response: to stderr, or appended to --receipt (one per line; restarting the server appends to the same file). A request no handler runs — a 404, /openapi.json — has none.
A stream or socket route is a load error with an auditor, because its body leaves while it runs, before the receipt exists (for a socket route the message says is a socket route). Serve those from a separate server without an auditor:
Runtime error: serve --auditor: route `GET /events` is a stream route: its body leaves while it runs, before the request's sealed receipt exists
inputs/outputsare those of the request.- A cron job or an agent outside a request has no receipt, so an audited operation in a private
branch there is still a label_violation ([serve] [cron] job 'job' failed: label_violation: reveal called under private control flow …).
- The
--receiptfile is opened when the server starts. Deleting it while the server runs does not
make the server reopen it: the requests still answer 200 and their receipts go to the deleted file, so they are lost. Rotate by restarting.
- The rest of
--receipt(bad path,fd:Non Windows, no auditor) fails at startup as underrun.
What runs in a private branch, and what still does not§
| In a branch decided by a private value, with an auditor | |
|---|---|
reveal | runs; its capability check and audit line go into the envelope |
sign and the signing builtins (ed25519_sign, …) | run; same |
| the wallet operations | their capability check and audit line go into the envelope, but a builtin that returns a new secret there (mnemonic_generate, hd_derive, …) then fails with a secret is already opaque; use private on the value you compute — make keys outside the branch |
spend | refused (label_violation: spend called under private control flow …): it spends a shared budget, and whether the next spend fits would tell what the branch did |
a signature that counts against SYNSEMA_SIGN_CEILING | refused; the error comes out as Runtime error: private(app) |
secret(), print, write_file and every other sink | still a label_violation, as without an auditor: an auditor seals audit lines, it does not open the walls. Read the secret before the branch |
declassify | runs, and is left out of everything (no [INF] declassify line, nothing sealed): how many there were would tell the branch |
The seed and the clock§
random()andrandom_int()come from a seed recorded in the envelope (the header carries
seed_sha256, its commitment). Each item of a parallel_map has its own stream, also under --replay. Keys and tokens (random_bytes, token, key generation) still come from the OS and are not reproduced.
- Every
now()reading is recorded, as text ("1792711237.7256045"), so it comes back bit for bit.
The readings take at most a quarter of the envelope; past that they are counted (clock_truncated in the opened receipt) and never push out an entry.
- With labels the clock is fenced:
now()gives the time of the last public moment — a read
from outside (read_file) or a builtin with an effect (write_file) that ran with the program counter public. Pure computation, print and eprint do not move it, so two now() around a private branch (or around any computation) read the same time and their difference does not measure how long the branch took. Without labels the clock is the real one, only recorded.
-- Doc example: a sealed receipt for an auditor (engine v0.6.46+).
-- Runs ONLY with `--labels --auditor <public key>`: without an auditor, the `reveal`
-- inside a branch decided by a private value is a label_violation (its audit line
-- would tell which way the branch went). With one, it runs and its audit line goes
-- into the envelope only the auditor opens. `synsema test` has no --auditor, so this
-- tree is run, verified, opened and replayed (see the CI step that runs it).
intent: "doc example: sealed receipt"
require secret("DEMO_TOKEN")
require reveal("DEMO_TOKEN")
require random
require time
let token be secret("DEMO_TOKEN", "not-a-real-token") -- outside the branch: secret() is a sink too
let tier be private(7, "app")
when tier > 5
let plain be reveal(token) -- audited into the envelope, not reveal.log
-- The seed of random() is recorded in the envelope, and so is each clock reading:
-- `synsema run --replay <opened receipt>` gives these same numbers back.
let draw be random_int(1, 1000000)
print("draw: " + text(draw))
print("started at: " + text(now()))Opening and verifying — synsema receipt open / verify§
synsema receipt verify <file>checks the signature. One receipt: prints `{"ok": true,
"header": {…}}, exit 0; a bad one prints the reason on stderr, exit 1 (the receipt's signature does not verify (the header, the public part, the envelope or the root was changed)). A file of serve --receipt (one receipt per line): checks all of them and prints {"ok": false, "receipts": 3, "bad": [{"line": 2, "error": "…"}]} (exit 1 if any is bad), also naming a line that begins like a receipt and is not one (the line names a sealed receipt but is not one (cut or changed)). Each receipt stands alone: a receipt deleted whole from the file, or cut before its first key, is not noticed. A file with nothing that looks like a receipt (or a single receipt cut short): <file> has no sealed receipt`, exit 1.
synsema receipt open <file> --key <private | @file> [--line N] (the auditor) checks the signature, opens the envelope and checks the chain. verify checks the signature only; open also checks the chain, so an entry dropped from the public part is caught there even when the holder of the signing key re-signed the receipt. It prints one JSON line:
{"header": {…},
"entries": [{"seq": 0, "sealed": false, "entry": {…}},
{"seq": 5, "sealed": true, "entry": {"kind": "capability", "capability": "reveal(\"TOK\")", "…": "…"}},
{"seq": 6, "sealed": true, "entry": {"kind": "op", "line": "… reveal result=granted name=TOK at=p.syn:10 …", "log": "reveal.log"}}],
"seed": "<hex>", "clock": ["1792740972.0779805"], "truncated": 0}
entriesis the whole run in order, public and sealed;clock_truncatedappears only when
readings were dropped.
--keytakes the private key itself or@file. In a file with several receipts,--line N
(1, 2, …) picks one; without it: <file> has 2 sealed receipts (lines 1, 2): pick one with --line N.
- Errors, all exit 1 unless noted: the signature errors of
verify; `the envelope does not open
with this key (wrong key, or the envelope or the header was changed); the chain does not verify: the entries are not the ones the run signed; --key is not base64url: …; line 5 of <file> is not a sealed receipt; --key <auditor private key> is required (exit 2); --line takes a line number (1, 2, …) (exit 2); cannot read <file>: <OS error>`.
Running it again — synsema run --replay <opened receipt>§
synsema run --replay opened.json program.syn runs the program again with the recorded seed and the recorded clock readings, and compares each input it reads, in order, with the input the recorded run read (source and sha256). At the first one that differs it stops, naming it:
Runtime error: p.syn:5:19: replay: input #1 differs from the recorded run — read_file returned sha256 27dd…, the recorded run read read_file with sha256 2c8b…
When it ends it writes a line on stderr: replay: the 1 inputs match the recorded run (same seed, same clock readings) (exit 0), or replay: the run read 0 of the 1 inputs of the recorded run (exit 1).
- It takes the opened receipt: `--replay: run.json is not an opened receipt (run synsema receipt
open <receipt> --key <private> first) (exit 2). Pass --labels` if the run had it.
- It cannot be combined with
--auditor,--explain,--format jsonor--attest: `--replay runs
the program again against an opened receipt; drop --auditor/--explain/--format json/--attest (exit 2). serve has no --replay`.
- It does not check the program: it replays whatever file you give it. Compare
header.program_sha with synsema code sha program.syn first.
- Effects are real: the program runs again for real, so a replay writes its files again. Replay
where its effects are harmless.
now()readings past the recorded ones (a program that reads the clock more often, or readings
the envelope cut) come from the real clock, without a warning, and the final line still says same clock readings. When the envelope cut something, it warns first: synsema run --replay: warning: the recorded run truncated N sealed entries; the comparison covers what the envelope kept.
What it does not hide§
- That the run touched private data:
stepsis alreadynull(inrun --format json, in
run --attest).
- The wall-clock time of the run:
startedis in the header, and anyone sees when the receipt
came out. (For wasm, see WebAssembly.)
- Whether it stopped on an error or a limit: the exit code and the response are public, and an
error run still writes its receipt. One bit per run, or per request.
- The sealed receipt is not
receipt(): that is the Verifiable Credential of
Agent identity, which the program asks for and publishes itself.
Error diagnostics (opt-in)§
Plain run prints a stable one-liner (Runtime error: file:line:col: msg). For a rich report — source context, call stack, visible variables, classification, suggestions — opt in:
synsema run --explain program.syn # human-readable, on stderr
synsema run --explain --format json program.syn # structured, for tools/agents
The exit code is unchanged (1 on failure, 0 on success) either way.