Reference

Architecture

One schema feeds three generators. Three runtimes hold the mirrors. A small binary wire carries writes in and change-sets out. This page is the tour, and the specification in the repository is the contract.

The shape of it#

Undra is a Rust core, a schema extracted from it, three code generators, and three platform runtimes. Your screens sit on generated bindings; the bindings sit on a runtime package; the runtime talks to the core through a transport that is an ABI, not a framework.

SwiftUIhand-writtenGenerated Swift packageidiomatic, generatedUndraRuntimemirror · codecs · adaptersC ABI in an XCFrameworkno UI code crosses itJetpack Composehand-writtenGenerated Gradle moduleidiomatic, generatedundra-runtimemirror · codecs · adaptersJNI in libundra_core.sono UI code crosses itReacthand-writtenReact Nativehand-writtenGenerated npm packageidiomatic, generated@undra/runtimemirror · codecs · adapterswasm, worker, WebSocket or JSIno UI code crosses itThe Rust corestores · queries · mutations · persistence · ports, one codebase
Each platform has the same four layers; only the bottom one is platform-specific machinery. React Native shares the web's two middle layers.

The core is one codebase. On each platform it is linked the way that platform expects: a prelinked static library in an XCFramework on iOS, a .so over JNI on Android, a wasm module on the web, each named after the core's namespace and reached through one table of entry points, so an app can carry more than one core. A React Native app links the iOS and Android builds as they are and reaches them from JavaScript through JSI, under the same generated package and @undra/runtime as the web (React Native). During development the same core can instead run on your machine behind a WebSocket (undra dev).

From Rust to native code#

Your Rust core#[undra::store]#[undra::api]#[undra::query]#[undra::port]The schemaundra-metatypes, methods,ports, queries,signal ids, hashundra bindgengolden-filetestedSwift packageon UndraRuntimeGradle moduleon undra-runtimenpm packageon @undra/runtimethe only source of truth (R1)
Everything is derived from the schema. Bindgen emits three trees that depend only on the matching runtime package.

Everything starts with the macros. #[undra::api], #[undra::store], #[undra::port] and friends validate your code and register a description of each public type and function. Collected together, those descriptions are the schema: records, enums, objects, stores with their signals, functions, ports and queries. The CLI builds your core as a host library, loads it, asks for the schema, and hands it to undra bindgen, which writes a Swift package, a Gradle module and an npm package. Generated code depends only on the matching runtime package.

Three properties of this design are worth stating plainly:

The standard ports and their types are in every schema but are not generated into your bindings, because each runtime already implements them; see Ports & adapters.

The crates#

Thirteen Rust crates, and only one of them may contain unsafe.

CrateRole
undra-metathe schema model, canonical JSON, the hash, the FNV helpers and registration
undra-wirethe binary codec, envelope and patches; no unsafe, proptested and byte-fuzzed
undra-macrosthe attribute macros and their teaching diagnostics
undra-signalsSignal, Computed, Effect, transactions, change-set building, keyed patches
undra-runtimethe executor, core lock, object table, ports, timers, snapshots and panic guard
undra-portsthe ten standard ports, their records and the deterministic fakes
undra-querythe query client, mutations, retry, persistence and the offline queue
undra-ffithe C ABI, the JNI shim and the wasm exports: the only unsafe
undra-testkitthe testing kit: the harness, the recording format, the recorder and replayer, the cross-platform drift comparison, the fakes' conformance file
undra-transportthe WebSocket server and framing behind undra dev
undra-bindgenthe Swift, Kotlin and TypeScript generators
undra-clithe undra command
undrathe facade you depend on: prelude, macros, runtime, ports, query

The runtimes are runtimes/swift/UndraRuntime, runtimes/kotlin/undra-runtime and runtimes/ts/@undra/runtime. The Kotlin runtime depends on the standard library and kotlinx-coroutines only, and the TypeScript core package has no runtime dependencies.

The wire#

The wire is small on purpose. It is little-endian throughout, with no alignment and no padding. All lengths are u32. Decoders never panic on malformed input; they return a typed error.

TypeEncoding
integers, floatsfixed width, two's complement or IEEE 754
boolone byte, 0 or 1 (other values are rejected)
String, Bytesu32 length, then the bytes (strings validated as UTF-8)
Option<T>a tag byte, then the value if present
Vec<T>u32 count, then the items
Map<K, V>u32 count, then pairs, sorted by encoded key so the bytes are deterministic
Duration, Timestampi64 nanoseconds, i64 milliseconds since the Unix epoch
Uuid16 raw bytes
recordits fields in declaration order
enum, erroru16 variant index, then that variant's fields
Result<T, E>a tag byte, then T or E
object handleu64: low 32 bits slot, high 32 bits generation

Transports that cross a real pipe (the WebSocket and worker transports) wrap every message in a 23-byte envelope. In-process calls skip it and carry only the payload.

magicUNDR4 Bversionu162 Bschema hashu64 · FNV-1a8 Bkindu81 Bsequ324 Blenu324 Bpayloadlen bytesheader: 23 bytes, little-endian, no padding
The envelope frames every message on the WebSocket and worker transports. In-process calls carry only the payload.

The kind byte says what the payload is:

KindNameDirection
1, 2Call, Replyhost to core, core to host
3ChangeSetcore to host
4, 5PortCall, PortReplycore to host, host to core
6, 7Cancel, StreamCredithost to core
8StreamItemcore to host
9, 10Observe, Releasehost to core
11Eventhost to core (connectivity, lifecycle)
12Helloboth: Undra version, schema hash, platform, mode
13Logcore to host
14TimerFiredhost to core
15, 16Snapshot, Restorecore to host, host to core

A call carries a target (free function, object method, constructor or lazy page), the object handle, the method id, a call id the host chose, and the arguments encoded in order. The reply carries the call id and a status: 0 ok, 1 a typed error, 2 a panic (message and backtrace), 3 cancelled, 4 a stream was opened, 5 a bad request such as an unknown method or a schema mismatch. A failure is always a typed value; nothing escapes as an abort.

A change-set is how state comes back. It carries a transaction id and one entry per dirty observed signal.

ChangeSet payloadtxn_id u64count u32entryentry… × countone entry per dirty observed signalhandle u64signal_id u32op u8len u32value len bytesop 0: full value · op 1: keyed patch (insert, remove, update, move, clear)op 2: lazy list invalidated · len lets a host skip an entry it cannot decode
Entries are ordered by signal id. A keyed patch inside an entry is a count and a list of operations.

An entry's op is 0 for a full value, 1 for a keyed patch, and 2 for "lazy list invalidated". A keyed patch is a count and a list of operations (Insert, Remove, Update, Move, Clear) applied in order to the host's copy; see Core concepts. The len field lets a host skip an entry it cannot decode.

Streams use credit-based backpressure. A host grants credit and the core sends at most that many items beyond what has been credited; generated bindings grant 16 on subscribe and top up when consumption drops below 8. A cancel closes the stream.

The boundary#

The native boundary is a C ABI of C-compatible types only. Each core exports one symbol named after its namespace, <namespace>_undra_api, which returns a table: its version and size, the schema hash, the namespace, and seventeen entry points (parameter lists abridged here). Two cores in one process are two tables, and nothing of one can bind to the other (ADR-044).

c
const UndraApi *playground_core_undra_api(void);

typedef struct UndraApi {
    uint32_t    abi_version;                       // 2
    uint32_t    size;                              // sizeof(UndraApi), for later additions
    uint64_t    schema_hash;
    const char *name_space;                        // "playground_core"
    UndraBuf  (*schema_json)(void);
    uint32_t  (*init)(cfg, len, reply_cb, changeset_cb, stream_cb, user);
    void      (*shutdown)(void);
    uint32_t  (*call)(ptr, len);                   // async reply arrives on reply_cb
    UndraBuf  (*call_sync)(ptr, len);              // reply returned directly
    void      (*cancel)(call_id);
    void      (*stream_credit)(call_id, credit);
    void      (*observe)(handle, signal_id, on);
    void      (*release)(handle);
    void      (*port_register)(port_id, cb, user);
    void      (*port_reply)(ptr, len);
    void      (*event)(port_id, method_id, ptr, len);
    void      (*timer_fired)(timer_id);
    UndraBuf  (*snapshot)(void);
    uint32_t  (*restore)(ptr, len);
    UndraBuf  (*stats_json)(void);
    void      (*buf_free)(UndraBuf buf);
} UndraApi;

Android reaches the same functions through a JNI shim that registers its natives once at load on the bindings' own class, so there is no per-call lookup and two cores never share one. The web has its own module per core, so it needs no table, and no wasm-bindgen: the module exports and imports use only i32, i64 and f64, a 64-bit handle is passed as two halves, and undra_poll() drives the executor. Clock, Rng and Log are bound to wasm imports, so a web app needs no adapter code for them.

Callbacks are where hosts get hurt, so the contract is explicit and enforced. A callback runs on whichever thread produced the event, possibly with the core lock held, so it must be short, thread-safe and must not throw. It must not call back into the core except the few entries that never take the lock (freeing a buffer, replying to a port call, granting stream credit, firing a timer, reading stats). Every other re-entry is refused with E_REENTRANT instead of deadlocking, in every build.

The synchronous call path is allocation-free once warm. A per-thread reply slot lets a synchronous method encode its whole reply into one reusable buffer, which is what moves a call through the C ABI to 49.8 ns (ADR-028); a counting allocator in the tests holds the lending entry to zero allocations and a C ABI call to exactly one, the buffer it hands back.

Threading#

Transports and modes#

ModeWhere the core runsUsed for
in-processlinked into the app (C ABI on iOS, JNI on Android)shipping
wasm-maina wasm module on the page's main threadsimple web apps
wasm-workerthe same module in a workerweb apps that want the main thread free
remoteyour machine, behind a WebSocketundra dev

Platform runtimes do the same jobs everywhere: load the core, allocate call ids, map replies to continuations, hold the mirror and apply change-sets on the main thread, implement observe and release, provide default adapters, implement the codecs, and refuse a mismatched core with a clear error that names both hashes.

Compatibility#

A UI and a core that were not built from the same schema must not talk. The schema hash is checked when the runtime attaches and surfaces as a typed mismatch error, and undra bindgen --check catches stale bindings at build time. Breaking the wire is a major version and needs an ADR; a changed schema still restores what the last build stored (Shipping an update).

Where drift is caught#

One schema removes most of the ways three platforms can disagree, and each remaining way has a check that fails:

Diagnostics#

Every macro mistake is an error with a stable code and a fixed shape: what happened, why, how to fix it, and where to read more. The same shape is used by schema validation and the runtime. This one is verbatim from the test suite:

text
error[undra::E0012]: `Vec<Box<dyn Any>>` cannot cross the boundary
  = note: trait objects have no wire representation
  = help: make `items` a concrete `#[undra::api]` type, or an enum listing the cases you need
  = docs: https://shreypdev.github.io/undra/docs/errors.html#E0012

The catalogue, by family:

CodesWhat they catch
E0001 to E0008unsupported types, generics, lifetimes, trait objects, misplaced Result or Stream, bad map keys, unsupported item shapes, unknown attributes
E0010 to E0013error enums without messages, a store and its impl block that disagree, trait objects in fields, a store that cannot be restored automatically
E0020 to E0022&mut self or self receivers, non-Send async futures
E0030 to E0033port methods with non-wire parameters, invalid port shapes, an error type that cannot represent an unavailable port
E0040 to E0042query and mutation keys, signatures and return types
E0050 to E0052schema validation: duplicate names, names that collide after case conversion, clashes with the standard library
E0060 to E0064look-alike built-in names, aliased types, runtime port failures, Option<Option<T>>, objects used as values

The CLI has its own C00NN codes with the same shape; the error-codes page lists all of them, each with real messages from the test suite.

The constitution#

Twelve rules, enforced in review, not aspirational. They are the reason the project reads the way it does.

RuleIn shortWhy it matters
R1The schema is the only truth. A public type or function exists only if the schema can describe it.Three languages cannot drift from each other if all three are derived from one description.
R2unsafe lives in undra-ffi only, plus one schema loader in the CLI. Every block has a // SAFETY: comment; every other crate forbids it.The audit surface is one crate, and it is the one reviewed under ASan and Miri.
R3Generated code must pass native review: a Swift, Kotlin or TypeScript engineer who has never seen Rust would write it that way. Golden files lock it.Adoption depends on the output feeling native, not generated.
R4Every feature lands whole: unit tests, contract scenarios, a benchmark if the boundary is touched, docs on every pub item.No half-features and no untested boundary.
R5Reads never cross the boundary; writes cross once per transaction. No generated getter calls into the core.This is the performance model. Everything else serves it.
R6Nothing escapes as a panic or an abort. Every boundary entry is guarded; every error is a typed value.A bug in the core must never take the app down with an unwinding crash.
R7Compatibility is checked at load (schema hash) and at build. Breaking the wire is a major version and an ADR.Mismatched halves are refused loudly instead of misdecoding quietly.
R8Macro errors teach: a code, what, why, fix, docs link.The Rust-side learning curve is the adoption risk.
R9Budgets are tests. Benchmarks live in bench/ and regressions fail CI.Performance claims are kept true by the build, not by memory.
R10We ship on it first: examples/playground uses only public APIs.If the reference app needs a private door, the public API is wrong.
R11Boundary changes need an ADR before code.The wire, the runtime model, the threading model and generated shapes are expensive to change later.
R12The core is deterministic: no wall clock, randomness or threads outside the Clock, Rng and Timer ports and the runtime.Deterministic cores are testable with fakes and replayable.

The engineering standards behind them: Rust 2024 edition with MSRV 1.85, clippy -D warnings, no anyhow in library crates, no println! in library code (logs go through the Log port), and no dependency that does not build for wasm32, iOS and Android.

Decisions on record#

The big decisions are written down as ADRs before the code. The ones in the tree today:

ADRDecision
018Query handles and the dispatch layer
019Signals: transactional delivery
020Signals: delivery order and the round cap
021Signals: contained misuse
022Handle generations and the snapshot floor
023Runtime delivery, re-entry, restore and shutdown
024The standard library is not generated
025Ports: typed outcomes and schema identity
026The FFI host contract: lifetimes and ownership
027Keyed list operations are recorded, not diffed
028The synchronous reply slot

What Undra leaves out#

By design, Undra has no sync engine, no hosted services and no shared UI of any kind. Storage is a port the platform implements (Kv, SecureStore, Fs, and the opt-in Db over SQLite), not a database the core owns. Those are choices recorded in the specification and the roadmap, not gaps.

Read the source of truth#

Edit this page on GitHub