Patrick Lidstone
Self-hosted

Path OS

A desktop operating system in Rust, for x86-64 and later Apple Silicon. It takes the Psion Series 5's user interface - the last one that was genuinely quiet, fast and predictable - gives it a real window server and real protected multitasking, and builds it on a kernel that already boots.

Path OS. Patrick's home operating system. Two words, not the Greek one.

The part that is not like other systems is one decision, and everything else is shaped to fit it: the semantic tree is the UI, not a shadow of it.

Two applications, each its own OS process

The inversion

Every desktop operating system in use today draws pixels first and publishes an accessibility tree second. The arrangement looks like this:

app  ──draws pixels──▶  compositor  ──▶  screen
 └───publishes─────▶  accessibility tree   (a second, optional, lossy structure)

Two structures. One is authoritative because it is what the user sees; the other is derived by hand and is therefore always slightly wrong. It is also optional in practice - an application that publishes nothing is merely inaccessible, not broken - so most publish little, and what they do publish drifts from what is actually on the glass. Thirty years of exhortation have not fixed that, and the only thing that reliably keeps two structures in step is not having two structures.

Path OS inverts it:

app ──registers elements──▶  ws ── owns the tree ──┬── renders ──▶ screen
                                                   └── answers ──▶ queries

An application does not paint a window. It registers a tree of typed elements with the window server - each with a role, a label, a value, a state and the set of actions it accepts - and the window server renders from that tree. An application that fails to describe an element does not get an undescribed element on screen. It gets no element on screen, because there is nothing else to draw from.

What falls out is that the OS knows what every pixel on the screen means, and it knows it for free rather than because anybody was conscientious. Reading the screen aloud, driving it by voice, scripting it, testing it, remoting it, or handing it to a language model as a set of tools are all queries against a structure that had to exist anyway. A screen reader, a script, a remote client and a model are four consumers of one thing, and building it once for all four is the argument the whole design rests on.

What an element carries

Every node in the tree has an id stable within its window, a role from a closed enum, a label, a value where that differs from the label, a state, an actions set, a rect, and its relations to other elements. Here is a screen and its description, which are the same object seen twice:

A Series 5 screen, drawn from semantics

Window "Data — Contacts"                                    can: Focus, Dismiss
ListItem "Doyle, Patrick" = "01423 500 900"     [selected]  can: Activate, Focus, Select
TextField "Surname" = "Doyle"                   [required]  can: Focus, SetValue
TextField "Email" = "not a valid address"       [invalid]   can: Focus, SetValue
Checkbox "Send Christmas card"                  [checked]   can: Activate, Focus, Toggle
Button "Delete"                                 [disabled]  can: Activate, Focus

Nothing was published separately to produce that. It is the tree the picture was rendered from, printed.

role and actions are closed sets. An application cannot invent a role, because a role the OS has never heard of is one it cannot describe or operate - which is precisely the failure being designed out. The window server refuses an unlabelled control outright, which is the design working, although the refusal arrives at run time rather than at compile time and that is a real weakness.

The handheld on a bigger screen

Path OS is diy - the Series 5 rebuilt natively for an ESP32-S3 - on a desktop. The same interface, the same control framework, the same closed widget set, ported from C++ to Rust and given the two things the handheld could not have. The first is a real window server: diy has one implicit full-screen surface and one accumulated dirty rectangle, and a menu pane there is a control that happens to be drawn last rather than a window with a z-order. The second is protected multitasking, which an ESP32-S3 cannot do at all because it has no MMU - the honest word for what that hardware offers is containment. On the Mac the protection is the host's and real; on metal it is M5 and not forked yet.

What travels is the interface and the lineage underneath it. What is new is the tree. diy draws through a CWindowGc like the machine it copies, so an element's semantics are implicit in the widget object and published nowhere. Path OS keeps the widgets and inverts what they produce: an eikon builder attaches the role, label and actions by construction, and the pixels come out of that. The same interface, arrived at from the other end.

Why the Psion's interface is the right base

That inversion is only affordable because the Series 5's interface is a closed set of well-understood controls, drawn by the system rather than by each application reinventing them. A general-purpose modern toolkit cannot do this - its widgets are arbitrary compositions of arbitrary drawing, so the system genuinely does not know what they are. EIKON's were not, and the control framework underneath them already had the shape being described here.

Choosing a 1997 palmtop toolkit as the base for a semantic-first design is therefore less nostalgic than it sounds, though I would be lying if I said nostalgia had nothing to do with it. The Series 5 opened an application in well under a second on a 36 MHz ARM. There are several thousand times that budget available now and it should not all be needed.

The escape hatch, and its price

Some things genuinely are pixels: a chart, a sketch, a game, a rendered web page. The Canvas role exists for them. Its price is that the application must still say what the canvas is, and if it is interactive it must publish child elements describing its interactive contents. A web page is not one opaque canvas - the browser publishes its links, headings, fields and landmarks as real elements, which is why the browser is scheduled last. It is the hardest test of the whole idea and it should be taken when the answer can still be acted on.

Where the descriptions come from

An element's semantics have to be written by someone, and a design that relies on developers being diligent is the one just rejected. Three sources, in order of preference:

  • The record schema. A form over data gets its roles, labels, constraints and validation from the field definitions it is bound to. A field of type Text(40) labelled "Surname" and marked required becomes a TextField with a label, a Required state and a validated SetValue. Nothing is written twice and nothing can be omitted, because the schema had to exist for the data to be stored at all.
  • The role's own definition. A button's action set is a property of being a button. The application supplies a label and everything else is implied.
  • The application, explicitly. For the rest. This is the only case where a developer can be lazy and it is deliberately the smallest one.

Forms over data are the bulk of what a small machine actually shows, and they are exactly where somebody would otherwise hand-roll controls and forget to label them. Hand-building a form over stored data is a sign the schema is missing.

Where it comes from

Three finished-enough projects, cloned into upstream/ and never modified in place.

basic_alike contributes FlowMachine, an x86-64 Rust kernel that boots: GDT/TSS/IDT, Ring 3 with a W^X 4 KiB remap and NX, an int 0x80 syscall gate, separate user-mode binaries, SMP, virtio block and net, smoltcp and TLS. It also brings flowSTORE, which becomes the persistence layer, and the FlowBASIC interpreter, which becomes the automation host. The kernel core.

diy contributes the EPOC/EIKON toolkit reimplemented natively in C++ - descriptors, a 4bpp graphics layer with all fourteen draw modes, the control framework, the widget set and around twenty applications. The UI, to be ported to Rust.

web (esp32-browser) contributes an HTML/CSS/layout/paint engine written from scratch, already built to compile off-device against real test corpora. The browser, last.

None of the three was written with this in mind, which is the part that makes it work. Each was finished far enough to be trustworthy before it was needed for anything else.

The port keeps the design and drops the workarounds for 1997 C++. TRAP/Leave, the cleanup stack and the descriptor classes are not coming across: porting TRAP/Leave into a language with ? would be archaeology rather than engineering. What survives is the ideas that were good - explicit failure, no hidden allocation, bounded buffers - expressed as Result, slices and Vec.

Two applications, two processes

The picture at the top is the milestone. Calculator and Cards are separate OS processes. They connect to the window server over a socket and speak only Request and Event. Neither draws anything and neither knows where its window is: the server owns the tree, so the server owns the geometry, so it tiles. Kill one and the other carries on.

The picture is byte-identical to the one from the earlier in-process build. That is the result worth having - the transport changed and no application logic did, because the protocol was defined as plain data from the first commit. No handles into window server memory, no callbacks, no shared mutable state. A message means the same thing whether it travelled a function call, a socket or a network, and element rectangles are in window coordinates so a window can be placed anywhere by anyone. That cost a little discipline early, in the form of in-process calls passing copies where they could have passed references, and it is the difference between remoting being a feature and remoting being a rewrite.

Because the pixels are derived rather than authoritative, the derivation can move. A remote session is not a second mechanism bolted on: it is the same tree, sent to a consumer that renders it at the far end rather than to a renderer that happens to be on the same machine. That is what separates it from VNC or RDP, which ship pixels because pixels are all their servers have. A 1024x768 screen at 32bpp is 3.1 MB per full frame; the tree behind it is a few kilobytes, and it arrives knowing what everything on it is.

Remote control is therefore first party - a property of the design rather than something to be added on top of it.

Layout belongs to the server

Applications do not compute rectangles. They declare containers with an axis and children with a sizing intent - Natural, Fixed(n) or Grow(w) - and the window server computes the geometry. Element::rect is an output, written by the server and read by anyone who asks. An application that sets one is ignored.

This looked like a preference and it is not. A remote client rendering the tree at the far end, or a portable rendering it on a 640x240 screen, cannot re-lay-out a tree full of absolute coordinates computed by an application that is not there. Three things fall out that were otherwise going to be hard: large text becomes a layout change rather than a special case, because the server owns both the theme and the geometry; tiling stops fighting the applications; and applications stop hard-coding the height of a title bar, which is a number they should never have known.

The whole framework an application implements is three methods - a title, a function from a size to a vector of elements, and an event handler that returns whether anything changed. There is no drawing, no layout arithmetic, no main loop and no window handling. Calc lost about two thirds of its size in the port from diy, because most of what was there was interface.

Menus, declared by the application and opened by the server

Menus, cascades, scrollbars, the caret, text selection and the Series 5 toolbar drawer are all the server's. An application declares that a menu exists and what is in it; the server opens it, routes the keys, greys out what is disabled and tells the application afterwards what was chosen. The rule is that the server knows what a control is and only the application knows what it means.

Input routing is an ordered cast of directors, each answering one question: is this input mine? Nine of them at present, from capture at the top to application at the floor. A director must be pure - it decides, and it can be asked the same question again by ui.where_would_key_go without anything moving - which is what lets an agent check where a keystroke will land before sending it. Each one returns its reason as a sentence, so a log says "raw took it because Terminal takes keys undigested" rather than leaving somebody to guess.

The operation surface

Every operation the OS exposes is declared once in a registry, and the same declaration yields the native Rust call, an MCP tool definition, and the scope that decides whether it is exposed at all. There are 37 operations today. The default "assistant" profile exposes 33 of them; ui.clear_clipboard, window.close, layout.forget_empty and store.drop_collection are behind Manage and Admin and are hidden.

The alternative would have been two definitions - the real API and a hand-written MCP wrapper - which is exactly the arrangement rejected for accessibility trees, for exactly the same reason.

A tunable surface matters in both directions. A large tool list is a worse tool list: selection accuracy falls as the surface grows, and irrelevant tools crowd the context the actual task needs. Fifteen well-chosen operations beat two hundred. And exposure is a privilege decision - describe_screen and format_store are not the same kind of operation and should not be reachable under the same conditions.

One rule keeps it honest. An operation reachable over MCP must be reachable no more freely than the same operation through the interface. act() goes through the same gate as the pointer, so a disabled control cannot be driven by script. If the tool surface ever becomes a way to do something the interface itself forbids, it has stopped being an accessibility layer and become a privilege escalation.

ui.screen is a watchable subject with a revision counter, so a client is told when the screen moves rather than polling ui.describe_screen at an interval chosen by guessing. A blink changes pixels but not meaning, so the caret moves the repaint flag without moving the revision - waking every watcher twice a second to tell them nothing would train them to ignore the notification that matters. Building that found a latent bug that had nothing to do with watching: peer sockets are non-blocking and write_all treats WouldBlock as an error, so any reply larger than the socket buffer marked the connection dead. A whole-screen description is exactly that size, which meant the largest and most useful answer the OS can give was the one guaranteed to fail, silently, as a disconnect rather than an error.

Driving it from outside

The tree being queryable is the smaller half of the argument. The window server owns the layout and it owns input routing, so it also owns what an action means. act() on a button enters the same director chain and the same focus rules a mouse press enters, and the button is then pressed by the code that presses it when a person does.

Nothing is being screen-scraped and no input is being synthesised. There is no second path into an application to keep in step with the first one, and no simulation layer to drift away from what is really happening - which is the accessibility-tree argument again, one layer down. An external client acts on the ids the screen was rendered from rather than on coordinates recovered from a picture, and its surface is the operations its profile exposes rather than an inference about what the pixels afford. It can ask ui.where_would_key_go before sending a key instead of sending one and looking at what moved, and ui.screen's revision tells it whether the screen changed between reading and acting.

Being driveable by a model is a consequence of that rather than a feature added for it: a model handed the operation set works in the same nouns and verbs as the interface, not in a description of them. How well that works in practice is not yet evidence. M3 is not finished, and nothing here has been driven by a model for long enough to say anything useful about it.

Assistive devices

A screen reader on a conventional OS is reconstructing intent after the fact, from a structure that is optional, published by hand and usually behind the screen it claims to describe. What it does well it does in spite of that: heuristics for what a graphic probably is, guesses at reading order, per-application special cases.

Here the tree is the thing being rendered. Reading order is the order the server laid out. A role, a label, a value, a state and an action set are not inferred from anything, because they are what the picture was drawn from. A switch or scanning device needs the action set, which every element declares. A voice driver needs labels, which the server will not render a control without. An eye tracker needs rectangles, which the server computed and wrote. Large text is a layout change rather than a special case, for the same reason. None of those consumers needs anything the OS does not have to have anyway, and none of it depends on an application author having remembered something.

None of it is written. There is no screen reader, no voice input, no switch support and no eye tracking, and until one of them exists the claim is only that the structure they need is there and cannot be left out - not that it is sufficient. A screen reader is the obvious next consumer and would be a fair test of whether the tree carries enough.

The store is the filesystem

There is one store and it is the filesystem. Documents, application data and system state are all objects in it. There is no POSIX layer, only a path-like naming convention over store objects, which is naming rather than a filesystem. This is EPOC's own model, where a document was a stream in a store and the machine had no separate notion of a file with bytes in it.

The substrate is flowSTORE rather than SQLite: copy-on-write, a CRC32C on every block, and one atomic checksummed superblock commit, so a crash leaves the previous consistent state rather than a torn one. SQLite is not rejected on quality - diy chose it and made a good case. It is rejected as a substrate, because crash-safety by construction is the property an OS store must have and the hardest one to add afterwards, and flowSTORE already had it and was already Rust, already no_std, and already in the kernel being forked. SQL is a query language, not a storage engine; wanting SQL is a reason to write a query layer, which is scheduled, not a reason to adopt a second durability mechanism.

The known gap is that flowSTORE has no garbage collector, so a copy-on-write store grows monotonically. That is logged and must close before the store is relied on for anything real.

The same source on bare metal

The same source runs hosted and on metal. pathos-kernel boots under GRUB as a multiboot2 ELF on Proxmox VM 160, takes the framebuffer, and draws a Series 5 screen at 1024x768 using the same window server, renderer, graphics layer and toolkit that run on the Mac.

Path OS on bare metal

The strongest evidence is what is absent: there is not one #[cfg] for being on metal in any of those four crates. Nothing in them knows where it is. They are handed a bitmap and hand one back.

What was actually needed was three files - COM1 for serial, a static heap in .bss with a linked-list allocator over it, and the multiboot2 framebuffer tag plus a blit - and about two hundred lines. That the machine-specific part is that small is the point; the OS above it was already portable because it was written not knowing where it would run.

Two things from that exercise are worth recording. rust-lld ships with the Rust toolchain and links an x86-64 ELF perfectly well from macOS, so no cross binutils are needed - only grub-mkrescue genuinely wants a Linux box, and that step runs over ssh on the Proxmox node. And the kernel prints the semantic tree to the serial line at the end of boot, using the same query an operation would use, so a headless boot log is a complete account of what is on the display. That was the first time the description was the only way to check a screen. It worked.

The pointer is absolute, through QEMU's VMware backdoor at port 0x5658. A relative mouse cannot work here: the guest picks an origin, the console draws its cursor at an absolute position, and the constant offset between them is preserved by every delta. It is worse than it sounds because the pointer still works - menus open and buttons press, just not under the arrow the person is looking at.

The crates

abi geometry, the semantic element model, the protocol and its wire format
api the operation registry - every API declared once, exposed as MCP tools
gdi fourteen draw modes, pens, brushes, bitmaps, scalable type
render semantic tree to pixels, and the theme
store crash-safe blocks, collections, schemas, channels
ws the window server: tiling, focus, input routing, the retained tree
eikon the widget builders, each attaching its semantics by construction
app what an application links against
apps the applications, one binary each
platform what a machine has to provide: a display, an input source, a clock
kernel the bare-metal entry point
hosted the macOS backend, the desktop, and the tools

Around 30,000 lines of Rust. 482 tests pass, and one is ignored. tools/check.sh runs those and then builds every crate meant to run on metal as no_std for x86_64-unknown-none, because a claim like "the same source builds both ways" decays silently otherwise. It decayed once already: four crates were missing from that list, and one of them - the window server - had already stopped compiling for the bare target. A safety net only covers what is on it.

Where it has got to

Seven milestones, M0 to M6:

M0 Foundations The workspace builds and both build paths work end to end
M1 First light A window on the Mac with the ported graphics layer drawing a recognisable Series 5 screen
M2 Two apps Real processes, real IPC, the store, a real window server; two applications, either of which can crash without the other
M3 Talk to it The tree queryable and actionable from outside; a model handed the tool set can drive the desktop
M4 The desktop The application suite: System, Calc, Files, Settings, Data, Word, Term
M5 Metal All of the above, unchanged, on bare x86-64
M6 Connected Storage that survives reboots, a network stack, FlowBASIC automation, and the browser

M2 is the last one actually met. The work since has run into two others without finishing either: the operation registry, the MCP server and the watch loop are most of what M3 wants, and the kernel drawing on real hardware is the first inch of M5. The first commit is dated 1 August 2026, so none of this has had time to be proved by use.

The roadmap carries one re-sequencing that is worth mentioning because it was an error rather than a slip. Protected processes and preemptive scheduling were originally scheduled at M2 and moved to M5. The hosted backend gets isolation from host processes, which is real protection enforced by a real MMU, so M2's "a process that can crash without taking the other with it" was already satisfied. The kernel stage is what makes it true on metal, which is the definition of M5. The original plan had put it in the wrong place.

What is not built

The list is longer than the list of what is.

No processes on metal. The bare-metal demo builds its tree in-kernel rather than an application connecting over a socket. Per-process address spaces, an ELF loader, a scheduler and a syscall path are all things FlowMachine has and none of them is forked yet.

No store on metal. The virtio disk is attached and unused. pathos-store builds for the target; what is missing is a block device over virtio-blk.

A static heap. 64 MiB in .bss, not carved from the multiboot2 memory map. Enough to boot and draw. The interesting version needs a frame allocator.

Soft float on metal. x86_64-unknown-none has SSE off, so glyph rasterisation is emulated. Fast enough to draw a screen and not fast enough to animate one. Enabling SSE means saving FPU state on context switch, which is a decision for when there are contexts to switch.

Two applications, and they are small. Calculator and Cards. The suite in M4 - System, Files, Settings, Data, Word, Term - does not exist. The System shell comes first because it is the launcher and nothing else is reachable without it.

No garbage collector in the store, as above.

No network for applications, no FlowBASIC host, no browser. All M6.

No remote client. Nothing renders a tree at the far end, and there is no networking for one to arrive over. Who is allowed to attach from off the machine is unanswered too: the scope machinery exists and there is nothing yet to point it at.

No assistive clients. No screen reader, no voice input, no switch or scanning support, no eye tracking. Every element carries what each of them would read - role, label, value, state, actions, rectangle - which is a statement about the structure and not evidence that it is enough.

One caret for the machine, with no way to show where a second person or a model is working. The clipboard is a collection with history that survives a restart, but it cannot hold a copied semantic subtree - copying what an element means rather than how it reads - which is the natural thing for this OS to be able to do and is not built.