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.
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#
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:
- Names become ids at compile time. A type id is a hash of its name, a method id a hash of
Type.method, a port id a hash ofport.Trait, and a signal id is its position among the store's signal fields. Every platform agrees without a registry lookup. The hash is FNV-1a. - The schema has a hash. It is the FNV-1a hash of a canonical form: lists sorted by name, no whitespace, doc comments excluded, so two cores with the same public surface hash the same. Each binding carries the hash it was generated from, and the runtime compares it with the core's when it attaches.
- Generated code is reviewed like hand-written code. Golden files lock the output for all three languages, and the output is compiled and run in tests. A record becomes a Swift
struct, a Kotlindata classand a TypeScriptinterface; an error becomes a typed-throwsenum, a sealed class and anErrorsubclass. See the API overviews for Swift, Kotlin and TypeScript.
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.
| Crate | Role |
|---|---|
undra-meta | the schema model, canonical JSON, the hash, the FNV helpers and registration |
undra-wire | the binary codec, envelope and patches; no unsafe, proptested and byte-fuzzed |
undra-macros | the attribute macros and their teaching diagnostics |
undra-signals | Signal, Computed, Effect, transactions, change-set building, keyed patches |
undra-runtime | the executor, core lock, object table, ports, timers, snapshots and panic guard |
undra-ports | the ten standard ports, their records and the deterministic fakes |
undra-query | the query client, mutations, retry, persistence and the offline queue |
undra-ffi | the C ABI, the JNI shim and the wasm exports: the only unsafe |
undra-testkit | the testing kit: the harness, the recording format, the recorder and replayer, the cross-platform drift comparison, the fakes' conformance file |
undra-transport | the WebSocket server and framing behind undra dev |
undra-bindgen | the Swift, Kotlin and TypeScript generators |
undra-cli | the undra command |
undra | the 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.
| Type | Encoding |
|---|---|
| integers, floats | fixed width, two's complement or IEEE 754 |
bool | one byte, 0 or 1 (other values are rejected) |
String, Bytes | u32 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, Timestamp | i64 nanoseconds, i64 milliseconds since the Unix epoch |
Uuid | 16 raw bytes |
| record | its fields in declaration order |
| enum, error | u16 variant index, then that variant's fields |
Result<T, E> | a tag byte, then T or E |
| object handle | u64: 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.
The kind byte says what the payload is:
| Kind | Name | Direction |
|---|---|---|
| 1, 2 | Call, Reply | host to core, core to host |
| 3 | ChangeSet | core to host |
| 4, 5 | PortCall, PortReply | core to host, host to core |
| 6, 7 | Cancel, StreamCredit | host to core |
| 8 | StreamItem | core to host |
| 9, 10 | Observe, Release | host to core |
| 11 | Event | host to core (connectivity, lifecycle) |
| 12 | Hello | both: Undra version, schema hash, platform, mode |
| 13 | Log | core to host |
| 14 | TimerFired | host to core |
| 15, 16 | Snapshot, Restore | core 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.
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).
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#
- One mutator. The core has one lock. Whoever holds it is the core loop. A synchronous call from the host runs on the caller's thread holding that lock, so it costs a mutex acquire, not a thread hop.
- Async work. Async calls are tasks on a small executor with no tokio, polled by the core thread, one task per in-flight call. Cancelling a call drops its future.
- A blocking pool runs blocking work off the lock. It must not write signals, and debug builds refuse such writes.
- wasm is single threaded. The host calls
undra_poll()on the next microtask whenever the core asks to be scheduled. - Shutdown is orderly. Every call still in flight is answered as cancelled and every stream ends, each exactly once, before the threads are joined.
Transports and modes#
| Mode | Where the core runs | Used for |
|---|---|---|
| in-process | linked into the app (C ABI on iOS, JNI on Android) | shipping |
wasm-main | a wasm module on the page's main thread | simple web apps |
wasm-worker | the same module in a worker | web apps that want the main thread free |
remote | your machine, behind a WebSocket | undra 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:
- At load: the schema hash, on every platform, before a single call.
- At build:
undra bindgen --check, andundra_bindings_testunder Bazel, for committed bindings that are not what the schema generates. - In review:
undra schema diffmarks each change to the public API breaking or additive. - In the runtimes: the contract grid, 35 scenarios against the real core on Swift, Kotlin and TypeScript, every cell passing.
- In the hosts:
undra driftcompares recordings of one flow made on iOS, Android and the web: the calls each UI made and their order, what each platform's port adapters answered, the events each host pushed, what was observed, and the state every store ended in, with the arguments decoded through the schema (Testing). - In a custom port: the generated Swift compiles under Swift's default main-actor isolation, proven by a compile test in CI (Ports).
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:
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#E0012The catalogue, by family:
| Codes | What they catch |
|---|---|
| E0001 to E0008 | unsupported types, generics, lifetimes, trait objects, misplaced Result or Stream, bad map keys, unsupported item shapes, unknown attributes |
| E0010 to E0013 | error 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 E0033 | port methods with non-wire parameters, invalid port shapes, an error type that cannot represent an unavailable port |
| E0040 to E0042 | query and mutation keys, signatures and return types |
| E0050 to E0052 | schema validation: duplicate names, names that collide after case conversion, clashes with the standard library |
| E0060 to E0064 | look-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.
| Rule | In short | Why it matters |
|---|---|---|
| R1 | The 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. |
| R2 | unsafe 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. |
| R3 | Generated 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. |
| R4 | Every 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. |
| R5 | Reads 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. |
| R6 | Nothing 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. |
| R7 | Compatibility 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. |
| R8 | Macro errors teach: a code, what, why, fix, docs link. | The Rust-side learning curve is the adoption risk. |
| R9 | Budgets are tests. Benchmarks live in bench/ and regressions fail CI. | Performance claims are kept true by the build, not by memory. |
| R10 | We ship on it first: examples/playground uses only public APIs. | If the reference app needs a private door, the public API is wrong. |
| R11 | Boundary changes need an ADR before code. | The wire, the runtime model, the threading model and generated shapes are expensive to change later. |
| R12 | The 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:
| ADR | Decision |
|---|---|
| 018 | Query handles and the dispatch layer |
| 019 | Signals: transactional delivery |
| 020 | Signals: delivery order and the round cap |
| 021 | Signals: contained misuse |
| 022 | Handle generations and the snapshot floor |
| 023 | Runtime delivery, re-entry, restore and shutdown |
| 024 | The standard library is not generated |
| 025 | Ports: typed outcomes and schema identity |
| 026 | The FFI host contract: lifetimes and ownership |
| 027 | Keyed list operations are recorded, not diffed |
| 028 | The 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#
docs/SPEC.md: the binding specification for the wire, the ABI, the schema, the runtime model and the generated shapes. Code that disagrees with it is wrong.bench/RESULTS.md: every benchmark row, the machine, and what is still waiting for devices..10x/reviews/: the adversarial reviews: the four core crates (signals, runtime, macros, ffi) first, then every feature before it merged.contract-tests/scenarios.md: the 35 scenarios that define "the platforms agree".