A research language for spatiotemporal composability

Software you can unload.

revl components load, hot-swap and unload inside a running system, and “unloading leaves no residue” is a compile-time guarantee, not runtime discipline. Cordis made revertible effects a discipline; revl makes them a type system.

one checked front-end · six runtimes · emitted code validated by real compilers · compiler and runtime run client-side, nothing leaves the page

revl run --watch · live composition running
PgDatabase
provides db: Database
running
UserCache v1
requires db · provides cache
running
UserCache v2
requires db · provides cache
proposed
Api
requires cache: Cache
running
accumulator, UserCache v1
store = Map.new()store.drop()
store.insert("u1", …)store.remove("u1")
store.insert("u2", …)store.remove("u2")
✓ no residue, every effect reverted, LIFO (G7)
composition running, every mutation on the stack carries its inverse
One source · one checked IR · six hardened runtimes
Python cordis-py · reference TypeScript cordis · tsc-validated Rust cordis-rs · cargo check Java cordis4j · javac Go stc-go WebAssembly cordis-wasm · wasmtime

The idea

Every mutation carries its own way back.

Three constructs are the whole story: effect pairs a mutation with its inverse, emit admits the ones that have none, and unloading is the accumulated history run backwards. The compiler refuses everything else.

01 · EFFECT / UNDO

Declare the inverse, or it doesn’t compile

Every mutation is written as an effect … undo … pair. The runtime pushes each pair onto the component’s accumulator as it runs, state and its rollback, recorded together, checked at compile time (G4).

02 · EMIT

Irreversible? Then say so, out loud

Writing to a real database, sending an email: some things cannot be undone. revl doesn’t pretend otherwise: they must be marked emit, the service interface must declare emission, and every crossing lands on the audited G8 boundary surface. A tool can’t claim to be harmless when its body emits.

03 · UNLOAD = REWIND

Teardown is derived, not written

You never write cleanup code. Unloading replays the accumulator backwards, LIFO-complete over everything the component did (G7), and teardown itself cannot register new effects, by construction (G5). Hot-swap is just unload + re-admit, and both halves are checked.

component UserCache requires db: Database provides cache: Cache {
  let store = effect Map.new() undo store.drop()

  provide cache {
    fn put(key, value) {
      effect store.insert(key, value)
      undo   store.remove(key)
      emit db.execute(`INSERT INTO cache_log …`)
    }
  }
}
accumulator (LIFO)
Map.new() ⟲ store.drop()
insert("u1") ⟲ remove("u1")
insert("u2") ⟲ remove("u2")
each effect pushes its inverse
system boundary (G8)
emit db.execute(…), declared, audited
nothing crosses silently
CHECKED G4: every mutation has an inverse or an emit marker

The gate

The compiler is an admission gate, and refusal is a feature.

A rejected program doesn’t get an error code, it gets a verdict: which guarantee it broke, and the why-trace, the actual derivation the checker found. Dependency cycles, provision conflicts, silent mutations: refused before any code exists.

zsh, revl
$ 
Rejections name the guarantee.

Not “type error”: G3, dependency cycles are rejected at link time, and the trace shows the cycle itself, file and line for every hop.

Every rejection ships its fix.

Diagnostics are structured (code, guarantee, expected/actual, hint), the same document over the CLI, JSON, and MCP. revl explain G4 prints what the guarantee means and how to satisfy it.

The rejection suite is the spec.

80+ refused programs in examples/rejections/ are the checker’s executable specification, every guarantee has a program that proves it’s enforced.

Break it yourself in the playground →

G1 – G8

Eight guarantees, enforced before code exists.

Each one is a theorem from the spatiotemporal-composability paper, turned into something the compiler refuses to violate.

G1 compile

Every requirement is declared, undeclared access cannot be written. A component only sees what it requires.

G2 link

Provision disjointness. Two providers for one key refuse to link, per realm, so multi-tenant compositions stay coherent.

G3 link

Dependency cycles rejected, with the cycle itself as the why-trace, even though loading is dynamic.

G4 compile

Every mutation carries an inverse (undo) or admits irreversibility (emit). No silent third option.

G5 by construction

Teardown cannot register effects. Cleanup can’t dig new holes while filling old ones.

G6 compile

Code outside effect forms is pure, confinement is a type-level fact, not an escape analysis.

G7 by lowering

Derived teardown is LIFO-complete over accumulated effects. Unload order is a theorem, not a convention.

G8 compile

The boundary surface, externs, emissions, is enumerable. revl audit prints everything that can touch the outside world.

New on the frozen core

Time, state, async, instances: still revertible.

The core is frozen; the language keeps growing on top of it. Every addition keeps the same deal: declare the inverse, or admit the crossing. All four are checkable in the playground, and the playground can now boot them live.

timers, every / after try it →
component Heartbeat requires log: Log {
  every 30s { emit log.write("tick") }
  after 5m  { emit log.write("warmup done") }
}

A timer is a revertible schedule, its inverse is cancellation, derived like any teardown. Unload provably cancels; no orphaned interval outlives its component. Under test the clock is a coeffect: advance 35s makes the third tick an assertable step, never a wall-clock race.

state handoff, handoff try it →
component Cache provides cache: Store {
  handoff cache: Map[Str, Str]   // the state contract
  let m = effect Map.new() undo m.drop()
}

A hot-swap of a stateful provider used to mean a cold successor. Now the predecessor exports its declared shape and the successor accepts it, checked at admission, with shape drift refused as a why-trace. The cache stays warm across the swap.

async, Async[T], colored & checked see it refused →
extern emission async fn http_post(url: Str, body: Str) -> Str
  = @ts { return await fetch(url, body) }

service Http { emission fn post(url: Str, body: Str) -> Str }

Asynchrony is a declared property, like emission-ness: async function values, async host externs, awaited on py/ts and erased on go/rust. A sync method reaching an async extern has no in-flight window, so the gate refuses it (A1).

instances, spawn, attenuated try it →
component Router requires kv_a: Store requires kv_b: StoreB {
  let a = effect spawn TenantAWorker with { tag: "a" } undo a.dispose()
  let b = effect spawn TenantBWorker with { tag: "b" } undo b.dispose()
}

A spawn may narrow a child's capabilities, never widen them. Each tenant worker provably reaches only its own store, even though the router holds both: least authority per instance, enforced by G4.

Six runtimes

Write it once. Run it as Python, TypeScript, Rust, Java, Go, or sandboxed Wasm.

One front-end enforces G1–G8 and lowers to a single IR. Six emitters target six Cordis runtimes, and conformance is measured, not asserted: each tier’s output goes to that tier’s real compiler.

app.rvl one source check G1–G8 the admission gate IR frozen v1 py cordis-py ts tsc ✓ rust cargo ✓ java javac ✓ go stc-go wasm wasmtime ✓
Components compose across languages, live.

A Python component can require a service a Rust component provides, different processes, no shared address space. revl run --placement wires each seam with a generated proxy/stub pair, and a provider’s death becomes a reactive withdrawal, not an exception.

Migrate a live component between tiers.

swap UserCache --to rust: the running composition unwinds the component’s effects, re-admits it on another runtime, and the system never stops.

Turing-complete, demonstrated by execution.

Emitted Wasm runs fib(20) = 6765 on real wasmtime. The claim is checked by running the code, not by argument.

The component manager

truc: assemble, don’t install.

Components are petits bouts, little pieces, and a composition is what you get when you assemble them. Every other package manager fetches first and finds out later. truc admits every component through revl’s own gate before it joins the assembly: a piece that would break your composition is refused at assemble time, with a why-trace, not discovered at runtime, in production, by you.

zsh, truc
$ truc add audited_database
  fetched · pinned in truc.lock · vendored under trucs/

$ truc assemble --check
  dry run, resolve + admit, nothing touched

$ truc assemble
  resolve → admit (G2: no provider races, G4: no
  capability overreach) → compose
  ✓ every petit bout admitted

$ truc ship
  publish, policy-gated: official = gauntlet-graded,
  re-verified dossier · discoverability metadata enforced
Fetched is not admitted.

truc add only fetches and pins. Whether the composition still holds is assemble’s job, a separate, honest step, with --check as its dry run and truc rm refusing to strand a dependent.

Written in revl.

truc is itself a revl composition, its components pass through the same admission gate they run for you, in-process. The manager dogfoods the paradigm it manages.

One namespace with the compiler.

Every verb is also revl truc <verb>, so agents and CI reach the manager through the toolchain they already hold.

Read the truc doc →

Agent-native

Built for authors that are machines.

revl mcp serve runs the whole compiler as an MCP server. An agent proposing a component talks to the admission gate, not the filesystem, and a rejection comes back as a structured document the agent can act on, not a stack trace it has to parse.

Structured verdicts, machine-first.

Code, guarantee, expected/actual, fix hint, every diagnostic is a document. Measured on a 30-spec benchmark against real models, iterating against the real checker.

Safety hints derived from bodies.

revl mcp schema projects services to MCP tools whose readOnlyHint / destructiveHint come from the compiler; a tool cannot describe itself as harmless when its body emits.

Admission, not access.

Agent code enters a running system through revl_admit (checked against the live manifest), or through revl_gauntlet, which grades a candidate in isolation and returns a verdict dossier.

G4 for the management plane.

The verbs themselves are capabilities: an operator profile gates which MCP verbs a session may reach, so a read-only agent provably cannot revl_swap a running system.

revl_checkrevl_admit revl_planrevl_swap revl_rollbackrevl_gauntlet revl_quarantinerevl_repair revl_canaryrevl_ship revl_query_*revl_snapshot
agent→ revl_check
component Leaky {
  config { url: Str }
  let pool = effect Pool.open(config.url)
}
gate← refused, with the fix
{ "code": "G4", "line": 3,
  "message": "effect has no `undo` and
     `Pool.open` is not pure",
  "guarantee": "every mutation carries an inverse,
     or admits irreversibility with `emit`",
  "hint": "write `effect Pool.open(...) undo `,
     or mark the call `emit`" }
agent→ revl_admit · patched
component Pooled {
  config { url: Str }
  let pool = effect Pool.open(config.url)
             undo   pool.close()
}
gate← admitted into the live composition
{ "admissible": true,
  "provisions_gained": ["pool: Pool"],
  "emission_surface": "unchanged",
  "teardown": "derived, LIFO, no residue" }

The toolchain is the developer surface

The compiler answers questions, not just “yes” or “no”.

Because effects are revertible and boundaries are enumerable, questions that are undecidable elsewhere become cheap here.

revl plan

A dry run for a hot-swap: which provisions appear or withdraw, who gets diverted, teardown order, how the emission surface changes, before anything moves.

revl query

Who emits to X? What breaks if I withdraw C? Each answer says whether it’s exact or a conservative over-approximation.

hole[T], typed holes

An unfinished draft still type-checks; the hole becomes a reported obligation with an expected-type fill spec, and can never be admitted.

revl run --record, backwards replay

Step an activation back over its own accumulator: :back · :bisect · :inspect. Only revertible effects make time travel cheap.

why-traces

A G2/G3/G4 rejection ships the derivation, not just the verdict: put → writeThrough → audit.log.

fault test

fail at step 2; assert no residue: crash-safety as a declarable, runnable assertion in the language itself.

verified effect

An undo that’s wrong is worse than none. verified round-trips the inverse N randomized times on the real runtime and asserts baseline.

revl erase-report

Right-to-erasure evidence as a compiler artifact: proof one realm unwinds to nothing while every other realm stays untouched.

revl import · wit / openapi / mcp

An external contract becomes a typed service, and any revl service exports back to WIT. The boundary is typed in both directions.

revl quarantine

The gauntlet proves a candidate runs correctly; quarantine proves it cannot escape while doing so, physically, in the wasm sandbox, trap-on-escape, before it touches a hosted tier.

revl canary

Progressive delivery: run both generations at once, give the successor one realm slice, promote or revert on evidence: the rollback is derived, not written.

revl repair

A component faults and the system repairs itself, inside declared bounds, stopping for a human exactly when it would step outside them.

Break a component.
Watch the gate refuse it.

The full compiler runs in your browser, and so does the runtime. Boot a composition on cordis-py, call its operations, hot-swap your own edit into the running system (state crosses via handoff), then unload and watch the no-residue proof. The playground page is itself a revl composition: it boots itself on load, and you can withdraw, reload and hot-swap the parts of the page you're standing on. Nothing you type leaves the page.

quickstart
$ git clone https://github.com/inso1337/revl && cd revl
$ uv venv && uv pip install -e ".[test]"
$ python -m revl compile examples/user_cache.rvl   # source → checked IR
$ python -m revl mcp serve                         # the compiler as an agent gate