Release

Undra 1.2: no drift between three hosts

One Rust core already made three hosts agree on their logic, state and data. What still drifted was everything the schema cannot see: which calls each UI makes, what each platform's adapters send, how a custom port is written three times. 1.2 is about those three things.

What still drifted

A team spent a week building a trading app on Undra 1.1: one core, SwiftUI on iOS, Compose on Android, React on the web. The core was never the problem. Three things were, and each of them lived outside the schema:

  • A header the replay never saw. Their production API refused a request that every test had accepted: one platform's adapter sent Accept twice. Their replay server matched on method and URL alone, so the duplicate got through.
  • A wrong host on one platform. Orders went to the API host instead of the web host, on one platform only, and nothing compared what the three hosts asked for.
  • A port that did not compile. Their iOS credentials port, a plain class conforming to the generated protocol, failed to build under Xcode 26's default main-actor isolation, and the file also needed an import nobody had told them about.

The first two are drift at the boundary: the core is deterministic, so if the three apps fed it the same inputs they would have ended in the same state. The third is drift in the one piece of host code a schema cannot generate for you. 1.2 answers each.

undra drift

Undra's testing kit already records a session as one JSON document, the same format on every platform: the calls the host made, the replies, every change-set, every port call and its answer, every event the host pushed. undra dev --record writes one for the app it is serving. 1.2 adds the comparison:

shell
$ undra dev --record ios.json                              # the iOS app connected
$ undra dev --addr 127.0.0.1:7444 --record android.json    # the Android app, on a second server
$ undra drift ios.json android.json web.json
Drift: ios.json (reference) against android.json, web.json (schema 0x3c17cd5f59bb6f68)
Ignored: the replies of Clock.* and Rng.*, the arguments of Timer.*, the Idempotency-Key header of Http.request, and t

android  calls     Todos.toggle on Todos#0 #5: missing on android ({"id":"00000000-0000-0001-0000-000000000000"})
android  ports     Http.request #1: reply differs: ios {"body":{"status":200,...},"status":"ok"}, android {"body":{"status":500,...},"status":"ok"}
web      calls     Todos.add on Todos#0 #6: extra on web ({"title":"Pay the rent"})

3 divergences on 2 of 3 recordings

The first file is the reference; every other file is compared with it. What is compared: the calls each host made and their order, with the arguments decoded through the project's schema; what each platform's port adapters answered, per port and method; the events each host pushed (connectivity, lifecycle); what was observed; and the state every store ended in. What legitimately differs between two runs is ignored and said so in the report: clock and random readings, timer deadlines, idempotency keys and each entry's time. Handles are aligned by the constructor that made them and its ordinal, so a store created second on iOS is compared with the store created second on Android, whatever number each runtime gave it. With --exit-code the command fails on any divergence, which is how it goes into CI.

What a port drift looks like in the report: the Http.request each platform's core asked for is the same, and the answer is not. The API that refused a duplicate header on one platform answered that platform's call with a 400 and the others with a 200, which is one ports line naming the call and both replies; the wrong host is the same line with the wrong answer. The report shows what the core asked and what each platform answered; what the adapter put on the wire between the two is the next step (below). The record format is unchanged, so a recording made with 1.1 reads in 1.2.

A port that compiles on every platform

A custom port in Rust is a trait. On Kotlin it is an interface and on the web an object literal, and both compiled from day one. On Swift the generated protocol is Sendable, because the core calls it from its own thread. We reproduced the failure with Xcode 26.6 and found it is not where we guessed: under default main-actor isolation a class conforming to that protocol is inferred nonisolated, and a stateless one compiles as written. What fails is what a real port has: a mutable property in a Sendable class, a read of main-actor state from the witness, and, when the generated file is compiled inside a module built in that mode, the generated adapter itself, whose requirements the mode had made main-actor isolated. And the registration line needs import UndraRuntime, which nothing said.

1.2 makes every requirement nonisolated in the generated protocol, puts a five-line doc on it (the thread, what a conformer therefore is, the import), and generates a second form beside it: one function per port that takes a @Sendable closure per method. A @Sendable closure is nonisolated under every isolation mode, so there is nothing to conform to and nothing to annotate; an async one reads main-actor state through await MainActor.run, and a synchronous one that tries is refused by the compiler with a diagnostic that says so:

swift
import UndraRuntime

let locale = localePortImpl(hello: { "Bonjour" })
let adapters = Adapters.platformDefault.replacing(PortImplAdapter(portId: UndraIds.Ports.Locale.portId, impl: locale))
let core = try UndraPlaygroundCore.load(.inproc(adapters: adapters))

The protocol form stays, for a port with state worth a type. A compile test in CI builds both forms, a plain class per port and a closure registration that reads main-actor state, under -default-isolation MainActor, so the shape cannot regress. The cookbook has the same port on three platforms, with each snippet compiled by CI, and the playground's iOS app implements its Locale port in the new form. The decision is ADR-066.

The merge gate, measured

Nothing lands on main unless every workflow is green on the exact commit. That gate took 26 to 31 minutes on the last three green runs, and the numbers said why: a handful of macOS runners serve the whole queue, and four jobs ran their work in a row where nothing depended on the order (the macOS Bazel example ran four Bazel configurations one after another; the Rust job's cargo test --workspace spent two thirds of its time in the CLI's integration tests; the Swift job built the Xcode projects after the runtime's tests; Two cores built its Debug cores, then its Release cores).

So we split all four into halves that run side by side, and measured it on this release's own pull request. The Linux halves did what they were meant to: the Rust job's took 6 and 14 minutes where the one job took 17 to 21, and the Bazel example's 8 and 9 where it took 17. The macOS halves did not: only three macOS runners were free, because two other workflows a pull request starts were using the rest, and the extra jobs added ten runner-minutes of setup to a pool that was already full. The gate took 29 minutes. We kept the Linux splits and the order, put the macOS jobs back the way they were, and ran it again: green in 23 minutes, three to six minutes shorter than before and not the half we had hoped for, with what would shorten it from here written down: the macOS pool is the limit, and it is shared. The record, with both measurements, is ci-wall-time.md.

What is next

The duplicate header pointed at something undra drift reports but does not fix: the platform default adapters themselves differ. The five SSE adapters merge the core's headers in different ways, and the JDK Http adapter drops restricted headers where Swift passes them. The next step is a conformance file for the real adapters, recorded against one server and diffed in CI, the way the fakes are already held to one file. It needs a rule in the specification first (which header wins), so it is on the roadmap under Next, with the evidence.

Upgrading

undra upgrade moves a project's pins to 1.2.0 and prints the release's migration notes. Nothing on the wire, the ABI or the schema changed; bindings regenerate to the same public shapes plus the new Swift port builders. The testing page has the drift command, the ports page the three-platform port, and the roadmap what shipped and what is open.