Skip to content

Roadmap

Milestones gate on demonstrable behavior, not effort. Specs referenced must be review+ before implementation starts (workflow). Milestone numbers are stable anchors used across docs, ADRs and open questions; scope moves between them by revision (this document’s history), never by renumbering.

MilestoneStatusNotes
M0 Docs & environmentdone 2026-09-15doctor 0 failures, 3 tiers + 3 demos build, site builds
M0.5 Public foundationsdone 2026-09-16CI green, fjarr.io + fjarr.dev live, registrations, prior-art anonymization policy
M0.6 Private developmentplanned, soonErik, 2026-10-15: the repo private with CI on self-hosted runners only, the docs behind a Cloudflare login, the landing page and the packages public; public customer docs later (below)
M1 Core + extension API (camera video)done 2026-09-25slices 0–2 done and reviewed, 2.9 (baseline bump, ADR-0022) done 2026-09-18, 3a (browser lab) 2026-09-19, 3b (agent core) 2026-09-20, 3c (introspection + memory ladder) 2026-09-20, 4 (fjarr.camera on real sources) 2026-09-20, 5a/5b (blob frames, fjarr.introspect, the served viewer) 2026-09-21, 6a/6b (repair + rate control, passthrough) 2026-09-22, 7a/7b (the remaining fault rows, the latency harness) 2026-09-23/24 — every slice landed; the gate review found the ADR-0015 webhook half had never run and that standard-form TURN URLs were silently ignored by the agent, and fixed both. Gate met 2026-09-25: CI green and the first fully green nightly on the self-hosted runner, which also recorded the first trustworthy latency numbers — clean 56/79 ms, lossy 82/100, relay 69/102 against budgets of 120/200, 200/350 and 250/450, at 46–52 decoded fps
M2 Desktop spikes + fjarr.terminaldone 2026-09-272a terminal and 2c module loader 2026-09-25; 2b the desktop spikes on a dedicated Ryzen mini-PC, ADR-0006 accepted with numbers (E for GNOME, C second, A for X11 kiosks, forced connectors for headless robots)
M2.6 Encoder familiesplannedadded 2026-09-24 (ADR-0025): nvcodec and nvv4l2 behind the existing adapter; closes open question #2
M4.5 Network tunneldone 2026-09-27 (gate review)added 2026-09-23 after the ROS 2 tunnel spike (docs/27, ADR-0023, ADR-0024); depends only on M2.5, so it can be pulled earlier if a design partner asks
M2 – M8plannedrevised 2026-09-17 after the slice-3 planning (docs/23–26, ADR-0019–0022, the webrtcbin spike, the planning review): new M2.5 packaging milestone; M3 lightened

Docs 00–19 at review; ADRs 0001–0016 filed; devcontainer + compose + robot-sim + doctor working; buildable skeletons for all three libraries, three demos, website.

CI (lint, build, docs gates), website deployed via Cloudflare Workers Builds, registrations (fjarr.io + fjarr.dev, npm org @fjarr, GitHub org fjarrio connected to crates.io). Prior-art sources anonymized across the tree, history and site (docs/11).

Why. Erik takes Fjarr private while he settles the licence and the business model (2026-10-15): the repository and the developer docs leave public view, and what robots and integrators install stays public. Done soon, not now; nothing is switched before this milestone runs.

SurfaceAfter M0.6
GitHub fjarrio/fjarrprivate
CIself-hosted runners only, none of GitHub’s hosted runners
fjarr.io landing pagepublic
docs/ on the websitebehind Cloudflare Access: the internal developer docs
apt.fjarr.io, get.fjarr.io, ghcr.io/fjarrio/fjarr-agentpublic
npm @fjarr, crates.iomay be public; nothing is published before the licence decision (below)
demo.fjarr.io, signal.fjarr.iounchanged (already protected)

What the switch touches was swept on 2026-10-15 (four sweeps and a completeness review over the workflows, tools/ci, the website, packaging, shipped strings and every doc). The slices follow from it.

SliceScopeDone when
0.6a Runner poolat least four isolated amd64 slots (4 vCPU, 16 GB, 80–100 GB each; one ephemeral VM and Docker daemon per job, because the stack jobs bind fixed ports and tun-up creates fjarr0 on the host); one native arm64 runner that is always online (the arm64 packages are on every push, and ADR-0031’s “never emulated” holds); an ephemeral release runner that holds the apt key and never runs anything else; a provisioning script (Ubuntu 26.04, Docker and buildx, gh and aws CLIs, rustup, the TSan vm.mmap_rnd_bits sysctl, the tun module, one fixed runner uid, runner-agent updates and a monthly template rebuild); Docker Hub login or a pull-through mirror; timeout-minutes on every job and a concurrency rule for back-to-back pushes; the labels in .github/actionlint.yamlci.yml runs green on the pool, side by side with the hosted runners, for a week, within docs/30’s budget; e2e repeated on the pool shows its timing races no worse
0.6b Release on a private repocheck the org’s GitHub plan: on Free, a private repository has no environment secrets and no required reviewers, so release.yml would lose the apt key’s scoping and the manual promotion to stable (Team, or a release path that keeps both); artifact storage (Free allows about 500 MB, CI keeps about 1.6 GB of .debs): a shorter retention or the .debs in a private R2 prefix, with release.yml and tools/ci/ci-run.sh reading from there; fjarr-ci images private (they hold closed code) within the plan’s package storage; fjarr-agent stays public and anonymous pulls keep working; keyless image signing writes the repository and workflow name to the public Rekor log, which is accepted or replaced by a key; secret scanning and push protection, lost on a private Free repository, replaced by a local or CI secret scan; GitHub’s announced charge for self-hosted minutes in private repositories, checked when the switch runsa release from the private repository signs and publishes to apt testing, a person promotes it to stable, and the public image and install script work anonymously
0.6c Docs behind a loginone boundary for the docs (a separate host such as docs.fjarr.io is the cleanest; a /docs/ prefix also works), with the Starlight search index behind the same Access application; a noindex header, a landing-only sitemap and robots.txt; workers.dev and preview URLs off or protected; fjarr.dev pointed at the landing page; Cloudflare’s GitHub app granted the private repository; the Access application created by deploy/demo/cloudflare.py generalised (same login, a team allowlist); an ADR amending ADR-0014an anonymous visitor reaches the landing page and nothing of docs/ on any host, preview or search index; a team member logs in and reads the docs; pushing main still deploys
0.6d Public surfacesthe landing page: no GitHub links, no links into the internal docs, no “the threat model / business plan / specs are public” claims, and no licence or open-core wording while the licence is open (the meta description, the hero, section 10); a public install page with the apt key fingerprint, which install.sh names; /.well-known/security.txt with a security contact; the Documentation= URLs in the shipped systemd units pointed at public pages (in a release); debian/copyright without the private source link, and third-party notices for what the binaries bundle (the MIT and Apache Rust crates, the viewer’s JavaScript), which their licences require whatever Fjarr’s licence becomesevery link in a public artifact (the landing page, the packages, the image, install.sh) opens without a login
0.6e The switchthe docs and agent guides restated for a private repository and self-hosted CI (docs/12, 13, 19, 26, 30, 04, 15, CLAUDE.md, README.md, CONTRIBUTING.md); a new ADR (private repository, CI entirely on self-hosted runners) amending ADR-0033; every runs-on moved to the pool’s labels; then the repository flipped to private, forking disabled at the org, third-party actions pinned by SHAthe repository is private, ci.yml, the nightly, the lab workflows and a release all run green on self-hosted runners, and the public surfaces in the table above answer anonymously

Order: 0.6a and 0.6b first, while the repository is still public and CI can run in both places; 0.6c and 0.6d next, since they need nothing private; the flip last. Everything already public stays public: anyone who copied a version while it was public keeps the licence it carried, and archives may hold the website.

Customer documentation, later {#m06-customer-docs}

Section titled “Customer documentation, later {#m06-customer-docs}”

docs/ stays the team’s internal documentation. A separate, streamlined documentation for customers and users is public: its own tree outside docs/ and its own Starlight build on a public host, written from the internal docs without forking their facts, with a build guard that fails on a link into the internal docs. It takes over what customers read today: the install guide (the public half of docs/26), the driver catalog pages, the tunnel how-tos, the web libraries’ guide, the protocol schemas at their fjarr.io/protocol/ URLs, and the per-release versioning (open question 14, docs/19). Every shipped string that names an internal doc (the agent’s hints, fjarr-setup’s and fjarr-connect’s help and prompts, the shipped configuration comments, @fjarr/core’s errors) is pointed at it, and docs/00’s “integrates in a week from the docs” means these docs. Its own spec, written when it starts, says the hosts, the licence of its text and the support channel.

Open question #13. Until Erik decides, nothing new is published to npm or crates.io: a published version’s licence and repository link are permanent, and a crate publishes its source, which would make crates.io the one public copy of the Rust code. The licence files and fields (LICENSE, debian/copyright, Cargo.toml, the package.jsons, the image’s licence label), ADR-0011, ADR-0038’s framing, the open-core split in docs/03 and docs/32, and CONTRIBUTING.md’s CLA all follow the decision, in one change with a superseding ADR. The no-GPL rule for shipped artifacts stays under any licence.

M1 — Core + extension API, proven by fjarr.camera (done)

Section titled “M1 — Core + extension API, proven by fjarr.camera (done)”

Scope. Agent core (session lifecycle, FrameHub, DC router, reconnect ladder; libsoup-3 signaling per ADR-0017); Rust signaling with grants, webhooks, TURN minting (done, slice 1); the web core per docs/21: client-owned multi-session handles, the three subscription modes, the publish side (send / request / newest-wins publishers with deadman / ordered channels / bulk), demand-driven track delivery with visibility, the stats & health sampler, and the capability-specific core hooks docs/22 requires (page-level focus registry, preference/latencyMode acquire options, fjarr.core/time-sync). Camera capability as the reference implementation; the ADR-0007 webrtcbin-vs-webrtcsink spike; baseline adaptive bitrate. Extension API paper-validated — done: M1 API-fit review.

Slices (each lands on main, reviewed retrospectively — docs/20): 0 protocol + review ✔ · 1 Rust signaling ✔ · 2 web core + React ✔ (review) · 2.9 baseline bump ✔ — dev container, CI and images on Ubuntu 26.04 / GStreamer 1.28 (ADR-0022), doctor re-verified, the webrtcbin spike re-run on 1.28 (track removal settled on inactive; answerers set reuse-source-pads) · 3 in three increments (docs/23): 3a ✔ the browser lab (2026-09-19: browser service, @fjarr/e2e, wire tap, LoopbackAgent, frame stamp, fjarr-lab, CI job; the Chromium-answerer spike report on ADR-0007) · 3b ✔ the agent core (2026-09-20: libfjarr core loop, signaling client, session state machine, DataChannel router, FrameHub fan-out, producers/consumers on webrtcbin, the built-in fjarr.test, fjarr-opsim, the introspection walker and minimal endpoint (docs/24), real HS256 grants in the demo backend, demo-robot streaming the test pattern to the demo dashboard, the ASan and RAII gates) · 3c ✔ introspection completeness and the memory-safety ladder (2026-09-20: /events, /stats, /memory + checkpoints, /log, the diagnostics bundle, fjarr-lab introspect; TSan, the leaks-tracer bracketing and the soak as gates, valgrind/heaptrack/netem nightly on a runner prepared for a GPU machine; it found an upstream webrtcbin stats leak on day one — review) · 4 ✔ fjarr.camera (2026-09-20: tracks from config through the SourceFactory, the gst/test/v4l2/rtsp types, hot-plug by renegotiation, per-track failure that never rebuilds the plane, --probe-source for every type, the demo robot on a pattern, the lab’s RTSP simulator and the host webcam — review) · 5a ✔ demo wiring, protocol half (2026-09-21, docs/23: docs/08 blob frames on both tiers, fjarr.introspect, the pipeline feeds, <PipelineGraph> + the dashboard Diagnostics tab, the demo backend’s operator/developer roles — review) · 5b ✔ demo wiring, delivery half (2026-09-21: the viewer served from introspect.viewer_dir, make introspect opening it with the demo’s token, the CI-built fjarr-agent image running the demo robot under the lab smoke — unpublished until M2.5; review) · 6a ✔ repair and rate control (2026-09-22, docs/23: ADR-0007 closed on webrtcbin with the agent’s own TWCC estimator; NACK/RTX and keyframe feedback in the offer; banded shared-encoder targets with per-viewer tier switching; the lab gates under the netem profiles — review) · 6b ✔ passthrough (2026-09-22: a camera’s own H.264 untouched — no encoder for the track — with its substream as the thumbnail tier and adaptive: false without one; review) · 7 in two increments (docs/23): 7a ✔ (2026-09-23) the four robot-lifecycle fault rows nothing exercised — a killed agent, a wedged core loop against a fake notify-socket supervisor, a pipeline error through the escalation ladder, and an expired or skewed grant — plus a signaling-server restart under the real agent; it found and fixed a tier-framerate bug that stopped any camera below 30 fps from streaming at all; 7b ✔ (2026-09-24) the glass-to-glass harness on the existing frame stamp: coturn in the lab profile so the relay column exists and is asserted to be genuinely relayed, p50/p95 and time-to-first-frame under clean/lossy/relay, a loose ceiling in CI with the docs/16 budgets behind E2E_LATENCY_STRICT for the prepared runner, and make latency recording a labelled row that carries its own decoded fps so a CPU-limited machine cannot be mistaken for a slow network. Input-to-photon needs a robot-side input path and lands with M3.

Gate: the three-demo stack end-to-end — demo-robot (embedding libfjarr) streams 2 tracks to 3 browsers through the fjarr-server sidecar, the TS demo-backend minting grants and receiving webhooks per the ADR-0015 contract; reconnect + ICE restart demonstrated under fault injection; latency harness reporting against budgets; TS types conform to the golden fixtures (closes open question #15); written API-fit review for the remaining capabilities.

M2 — Desktop backend spikes + fjarr.terminal

Section titled “M2 — Desktop backend spikes + fjarr.terminal”

The five spikes per docs/07 close ADR-0006 with measurements — unattended access is the hard gate; cursor metadata, desktop-audio capture and monitor hot-plug events are measured per backend for M3. The winning backends are built as the runtime modules of ADR-0021 (the module loader lands here). In parallel, the terminal capability — the deliberately media-free second consumer of the extension API (two unrelated capabilities = minimum bar for “generic”) and the first user of the bulk byte channel. Slices (planned 2026-09-25; the three deliverables are unusually independent, so they land in this order for the reason each gives):

  • 2a ✔ (2026-09-25) fjarr.terminal. The pty over the bulk byte channel in both directions (docs/08), the xterm component, the ownership lease, the audit, and the config that makes it exist at all (docs/06, docs/10). It did what it was scheduled to do: the extension API needed one amendment, watch_readable, because a pty is a file descriptor and nothing in the interface could serve one without breaking the single-loop rule (docs/09). Measured: an interactive round trip of 11 ms against a 150 ms budget. First because it needs no hardware and no display server, and because an extension API with one consumer is not proven generic — it is camera video with extra structure. If the API is wrong, this is the cheapest moment to find out. Gate: interactive round trip < 150 ms on LAN; a disconnect leaves no orphan shell; every open and close audited; no extension-API change was needed, or the API is amended and the fit review re-run.
  • 2b — the desktop spikes and ADR-0006. Two phases (docs/07): unattended access for all four combinations first, since it eliminates; then the full criteria table for the survivors. On the gpu-desktop runner, because a rebooted machine with nobody logged in is the one thing a container cannot simulate. Phase 1 is specified to the point of being startable: a dedicated fjarr-spike account, auto-login toggled between runs, both the appliance and login-screen sub-cases, and an injection oracle that records what actually arrived rather than trusting a return value. Its one recorded caveat is that Wayland findings on an NVIDIA machine are provisional until seen on Intel. Gate: ADR-0006 accepted with numbers, not adjectives, and a go/no-go on unattended access naming the exact mechanism.
  • 2b is blocked on hardware (2026-09-25): the machine it needed is no longer available, and its question — what a real machine does after a real reboot — is precisely the one no container answers. The protocol is written and startable; it waits on a host, not on a decision. 2c runs first instead, which changes nothing about it: the loader is backend-agnostic, so it does not depend on which combination wins.
  • 2b ✔ (2026-09-27) on a dedicated spike machine: a Ryzen mini-PC on Ubuntu 26.04.1, which also answered the Intel/NVIDIA caveat because it runs Mesa. Stock 26.04 turned out to have no X11 session, which added a fifth candidate, E (mutter’s own interfaces). Phase 1 passed every combination for the appliance case in its own session type; none reaches GNOME’s login screen. Phase 2 measured latency, cost, cursor, audio, failure modes, three monitors, cable hot-plug and headless robots. Gate met: ADR-0006 accepted with numbers. E for stock Ubuntu, C second, A for X11 kiosks, and a forced connector for robots without a display.
  • 2c ✔ (2026-09-25) the module loader. A backend loads at runtime from a separate package (ADR-0021) and the always-present fjarr.desktop capability reports unavailable with the package to install when none matches — a robot with no desktop still passes --check, because that is the normal case. The seam’s version is in the exported symbol’s name, so a module built against an older one is not found rather than loaded and misread. Gate met: the loader creates a backend from a separate .so on the dev stack, and the doctor prints a desktop row either way.

Gate: ADR-0006 accepted with data; terminal accepted per docs/06; no extension-API changes needed for it (or the API amended + re-reviewed); a desktop backend loads as a module from a separate package on the dev stack.

M2.5 — Packaging & install (done) {#m25—packaging—install}

Section titled “M2.5 — Packaging & install (done) {#m25—packaging—install}”

Everything a robot needs to receive the product, so M3 can ship features into it rather than build the delivery: the apt repository and signing, the fjarr-agent package with its systemd unit (ADR-0019 addendum), fjarr-desktop-x11 / fjarr-desktop-wayland / fjarr-inputd / fjarr-tools, the container image split (base + per-vendor layers, arm64 — the core image itself is built and tested in CI from slice 5b and first published here, with the release process), the install script, fjarr-agent setup / --check / drivers, and the driver catalog format with the built-in entries (test, v4l2, rtsp, desktop backends) — docs/26. Vendor camera packages themselves are M3 (chosen by the design partner’s hardware). Also here: the browser lab profiling scenarios and docs/16 web budgets promoted to CI gates, and release process + docs versioning (open question #14). Once the agent runs on the spike machine, also try two things over fjarr.net and record the results in docs/27 (noted 2026-09-27, unverified): an RDP client to the robot’s own gnome-remote-desktop, and Nautilus mounting the robot over sftp (sftp://<user>@<tunnel address>/). Decided 2026-09-28 (ADR-0031, docs/26): apt and containers now, built once and packaged twice; Pixi, Nix and Yocto per customer as renderings of the same system profile; debhelper packages in a signed static repository on Cloudflare R2; one version for every artifact; tag → testing → a manual promotion to stable without a rebuild. Progress 2026-09-28: the .debs, libfjarr-dev, the system profile and --check, the image from the .debs, and the setup tool’s first commands: fjarr-agent net setup, net up (fjarr-net.service) and setup --undo net (docs/26), proven on the spike machine across a reboot. Later the same day: fjarr-agent setup (the first run: detection, server, device id and token, cameras as tracks, terminal, the agent started and online, --undo) and drivers list|detect|install with the built-in catalog (packaging/catalog.toml), proven on the spike machine against the lab server and in the package install test (docs/26). Then the signed repository on R2, install.sh behind get.fjarr.io, one version everywhere and the release workflow (0.1.1 on 2026-09-29, 0.1.2 on 2026-09-30), and the reference compose file with the image’s own tunnel entrypoint, proven by make compose-gate in CI with host networking. Closed 2026-09-30. The gate, met: the mini-PC reinstalled with Ubuntu 26.04 went from wget -qO- https://get.fjarr.io | sudo sh (0.1.2) to the test pattern in the demo dashboard with no hand-written config, sudo fjarr-agent --check all green; the compose robot passes setup, --check, ROS ordered on carrier, an unprivileged agent and the tunnel scenario on every push; packages and images for amd64 and arm64 in CI; tag → testing → stable twice. The fresh install found a real bug — /run/fjarr missing until the next boot, the package ordering tmpfiles before sysusers — fixed for 0.1.3 with an install test that now catches it. The two trials over fjarr.net worked: RDP with Remmina to the robot’s gnome-remote-desktop, and Nautilus over sftp:// (docs/27). Docs versioning (/vX.Y/ snapshots, docs/19) is postponed until the roadmap settles, by decision: the pages change too fast now for a snapshot to help. Moved to M3 (2026-09-28): the desktop packages and setup desktop. They configure the session helper and backend M3 builds; in M2.5 they would set up an auto-login for a helper that does not exist yet. Gate: a fresh Ubuntu 26.04 machine (the spike machine, reinstalled) goes from wget … | sudo sh to a test pattern in the dashboard with no hand-written config; a containerized robot runs from the reference compose file, tunnel included; --check reports a correct profile on both; the package set and images build for amd64 and arm64 in CI; a release goes tag → testing → stable without a rebuild.

EncoderAdapter’s second and third implementations per ADR-0025: the nvcodec family for discrete NVIDIA cards and nvv4l2 for Jetson, with the auto preference order, the memory-path rule, and the live-bitrate declaration that keeps rate control honest on a family that cannot take one. Plus the doctor probe per family, the platform matrix’s runner column, and the container plumbing that gives the dev image a GPU (NVIDIA_DRIVER_CAPABILITIES, which is why gst-inspect-1.0 nvcodec reported zero features the first time it was tried).

Here rather than post-M6 for two reasons. A seam with one hardware implementation is not proven generic, and this is the cheapest moment to find that out. And M2.5 already builds arm64 images and owns the driver catalog a Jetson package belongs in, so doing this next avoids opening the same boxes twice. nvcodec goes first because the hardware exists and everything below Fjarr is verified on it; nvv4l2 follows when a board does.

Gate: a camera streams through nvcodec on the gpu-desktop runner with no CPU colour conversion in the pipeline graph, rate control moves its bitrate while playing, auto picks VA-API on a machine that has both, the doctor names the family it chose and why, and the nightly runs the media suites on that runner. Jetson’s row in the matrix stays untested until a board is in CI, per ADR-0025.

fjarr.desktop: an operator sees the robot’s screens in the browser and uses them as their own, on a robot that nobody attends. Planned 2026-09-30 from an inventory of what exists. What exists is the backend module seam (ADR-0021), a fjarr.desktop that reports only its availability, the session core’s control domains and release_all_input, and the web client’s slice-2 primitives (focus registry, monitors, latencyMode, preference). The backends, the helper and the desktop view exist only as M2 spikes. Scope (docs/22, docs/07, ADR-0006, ADR-0028):

  • Backend E (mutter, GNOME on Wayland) first, then A (X11 kiosk). Backend C (the portals, for non-GNOME Wayland) is deferred until a robot needs it: E covers stock Ubuntu and A covers kiosks, and a third backend without a user is a third to keep working.
  • The session helper in C++ in the agent’s tree, owning D-Bus and PipeWire narrowing. Backend module E owns the stream, the cursor reader and libei (ADR-0028, addendum 2026-09-30).
  • The web client: DesktopInput in @fjarr/core, <DesktopView> and its hooks in @fjarr/react, and a Desktop panel in the demo dashboard (docs/22).
  • Ghost screens for headless robots, beside real monitors (ADR-0032, docs/26).
  • The test lab: headless mutter in CI, and the mini-PC as a shared nightly runner under fjarr-lab (ADR-0033).

Slices, in dependency order. Each ends in something demonstrable and tested:

SliceWhatDone when
3.0 Lab foundationA throwaway spike first: headless mutter in a container with no GPU gives RemoteDesktop, ScreenCast, a PipeWire frame and EIS input (ADR-0033’s assumption; if not, its fallback). Then the fixture in CI (docs/15), and fjarr-lab written and tested without a real runner (docs/12). Code only: no lab machine is neededCI captures a frame from a mutter desktop and sees injected input in its test window, on every push; fjarr-lab’s window, reservation and grace logic pass their tests
3.1 Seefjarr-desktop-session and backend module E for one monitor: the helper hands over the PipeWire descriptor, and the agent turns it into a video track that repeats its last frame on a static screen. <DesktopView> shows itthe fixture’s desktop is live in the demo dashboard, and the fixture asserts frames from a static screen
3.2 ControlDesktopInput and <DesktopView>’s input: pointer, wheel, keys by code, no auto-repeat, composition, reserved shortcuts and Keyboard Lock, release-all. libei injection in module E. The desktop control domain wired to it. An input-to-photon harnesskeys and Shift-level text reach the fixture’s test window and Alt+Tab reaches its compositor, and a character its US keymap lacks is refused by name; åäö type against a Swedish keymap (unit test; real typing on the mini-PC in 3.L); disconnecting mid-keydown leaves no key held (docs/15 safety); the input-to-photon harness runs against the fixture
3.L Lab machines (with Erik)The manual steps, done when the slices need hardware and not before: ssh and sudo on the mini-PC for development, registering it as a runner (a one-time token from the repository’s settings), installing fjarr-lab with its labels and CI window, and the first nightly on it. The GPU desktop joins the same way when it suits, as it is needed only for the nightly media jobsthe mini-PC runs a nightly job inside its window and fjarr-lab reserve holds it; 3.1 and 3.2 shown on real hardware: its screen in the dashboard, and input-to-photon within docs/16
3.3 ApplianceThe helper as a systemd user unit, with SO_PEERCRED and PipeWire narrowing (proven first; the gating item). fjarr-desktop-wayland and setup desktop / --undo desktop: auto-login account, GDM watchdog, helper, ghost screens and fjarr-agent display. The unattended-access testapt install fjarr-desktop-wayland plus setup desktop gives remote desktop after a reboot with nobody at the machine, nightly; --undo restores the machine; the agent cannot open the desktop user’s microphone
3.4 Multi-monitorOne track per monitor, diffed by wire id (docs/23); the monitors event before each re-offer; mode changes in place; zero monitors. The helper sends monitor-gone (mutter does not), and keeps a ghost from being primary while a real monitor is connected (docs/26). <DesktopLayout> and a monitor picker in the demo. Hot-plug in CI with mutter’s virtual monitors (desktop-hotplug)desktop-hotplug in CI; the docs/06 hot-plug criteria on the mini-PC with a real monitor, the DisplayPort chain and two ghosts, each with its own stable id
3.5 ComfortLocal cursor through Fjarr’s own PipeWire reader (docs/22); clipboard both ways (mutter’s interface); sharpness in the agent; per-session virtual monitorsthe cursor is drawn locally with no lag; copy and paste both ways in the fixture and on the mini-PC
3.6 Separate windowsThe portal-vs-route spike (open question #19, closed: the portal), then “Open in separate window”: a robot monitor in a window of its own, fullscreen there (docs/22)a robot monitor opens in a separate window with one click, streams there, takes input and goes fullscreen with Keyboard Lock (Alt+Tab reaches the robot); closing the dashboard tab closes it; the same in Firefox
3.7 X11 kioskBackend A and fjarr-desktop-x11: the xhost grant and the layout helper. robot-sim becomes its CI fixture, with scripted xrandr changesan X11 kiosk robot works from the package, tested on every push
3.8 ContainersA containerized robot’s desktop through the reference compose file, with the helper’s socket bind-mounted (docs/26)the compose gate extended to the desktop

Gate: the fjarr.desktop acceptance criteria (docs/06); input-to-photon within budget on the mini-PC; apt install fjarr-desktop-wayland plus setup desktop gives remote desktop on the mini-PC, unattended after a reboot, and --undo desktop restores it; the mini-PC headless with two ghost screens plus a real monitor, all streaming with distinct ids; an X11 kiosk from fjarr-desktop-x11; a containerized robot’s desktop through the reference compose file. No release until M3 closes (decided 2026-09-30).

Progress 2026-10-01: 3.0 done 2026-09-30 (the fixture and fjarr-lab in CI). 3.1 done: fjarr-desktop-session hands module E the PipeWire descriptor over fjarr-desktop-1 (docs/23); the agent offers desk-virtual-1, and pipewiresrc keepalive-time keeps a still screen producing frames. make desktop-e2e asserts both in CI through opsim. In the lab browser, the demo dashboard’s Desktop panel (<DesktopView>) decodes the fixture’s screen (web/e2e/tests/stack/dashboard.spec.ts). 3.2, agent side done: module E is the libei sender on the EIS socket the helper hands over, the agent maps KeyboardEvent.code to evdev, and text is typed through the robot’s keymap with xkbcommon. A spike showed no Unicode path on mutter 50 and libei 1.5, so docs/08 now says so. make desktop-e2e drives the fixture in CI. Clicks, keys, text, Alt+Tab and an untypable “å” all check against the window’s log, and the Shift held at session end is released. Input-to-photon is p50 21 / p95 24 ms on the dev machine, within the container. 3.2 done: DesktopInput in @fjarr/core and <DesktopView>’s input surface, with focus, Esc, IME through text, pointer capture, the non-passive wheel and the control domain. The demo’s Desktop panel has take-control, Ctrl+Alt+Del, Alt+Tab and fullscreen. In the lab browser real Chromium types into the fixture, and Shift held through an Esc is released on the robot (dashboard.spec.ts). 3.3 started; its gating item is proven: the helper narrows each handed PipeWire connection to the core, the stream’s node and the client-node factory (spikes/pipewire-narrowing). fixture-helper-check shows on every push that a live microphone, which an unnarrowed connection captures, yields nothing through the handed one. Then fjarr-desktop-wayland (the module, the helper and its user unit, the group, the watchdog), setup desktop / --undo desktop and fjarr-agent display with generated ghost EDIDs. All are tested in containers and the package install test, and display list was read on real hardware. What remains of 3.3 needs the mini-PC (3.L): a reboot into the automatic login, ghosts on real connectors, the watchdog bringing a killed session back, and the nightly unattended test.

3.L on the mini-PC, 2026-10-01 (Ryzen 7 5700U, Ubuntu 26.04.1, kernel 7.0, GNOME 50, Swedish layout, three Dells on one DisplayPort chain): fjarr-desktop-wayland and setup desktop --ghost-screens 1, then a reboot. desktop logged in by itself, the helper connected 3 s after the agent started, and mutter reported 4 monitors: the chain’s three and “Fjarr Ghost 1” on HDMI-A-1, whose EDID was ours. desktop-see 5/5 and desktop-control 12/12 ran from the dev machine over the LAN: åäö and AltGr’s ”@” typed through the robot’s own layout, a click landed on its pixel at 1920×1080, and input-to-photon was p50 52 ms, p95 59 ms (budget 150/250). The watchdog brought a killed session back in 51 s (two checks, then a GDM restart), with the helper, capture and input back a second later. Two bugs that only this hardware showed were fixed the same evening (0ce5475): the watchdog would have logged out a person at the machine, and amdgpu’s chain root was not recognised. Then, the same evening, #37 was answered: a real monitor on a ghost connector is found over DDC, and display list and --check fail on it. The mini-PC was registered as a runner (fjarr-lab, amd64, desktop, gnome, vaapi) under fjarr-lab with the 00:00–06:00 window. The unattended-access test is lab-desktop-prepare at 00:15 and lab-desktop-verify at 00:45, and its first run by hand passed every step from reboot to input (docs/15). 3.L done. One question goes to 3.4: a desktop account created by setup desktop has no saved monitor layout, and mutter made the ghost its primary monitor.

3.4, agent side done (2026-10-01): one track per monitor, diffed by wire id, with the monitors event before each re-offer. Two spikes first: a started RemoteDesktop session takes no new streams, and EIS’s absolute pointer covers only linked monitors. So the helper keeps one unlinked RemoteDesktop session for input and one ScreenCast session per capture. It sends monitor-gone itself, and makes a real monitor primary when mutter picks a ghost. The fixture plugs mutter virtual monitors, and desktop-hotplug passes 12/12. The new track was offered 101 ms after the plug. The first monitor’s longest gap was 117 ms, which is its keepalive, so nothing was dropped. The unplugged track left 21 ms after the unplug, and came back as the same track_id. The web side followed the same day: <DesktopLayout> arranges every monitor by its geometry, reflows on hot-plug, and hides exact mirrors. The demo’s Desktop panel has a monitor picker (primary, all, or one by stable id). In the lab browser, a monitor plugged into the fixture appeared in the layout streaming, and left it again when unplugged. Remaining in 3.4: the mini-PC run, with a real monitor, the DisplayPort chain and two ghosts.

3.5, the clipboard both ways (2026-10-03). A spike first (spikes/desktop-comfort): mutter’s clipboard works on the helper’s unlinked input session, both ways, given every text name an application may ask for, an answer to every transfer, blocking descriptors and a keyboard on the seat. The same spike showed the cursor arriving as SPA_META_Cursor on every pointer move, with its shape once per change, which is what the local cursor is built on next. Built: clipboard-offer, -read and -write (docs/08), the helper’s relay and module E’s ClipboardHandle (docs/23), DesktopClipboard (one per session) and <DesktopView>’s held-back paste chord (docs/22). In CI, opsim’s desktop-clipboard reads “robot says åäö” copied on the fixture and pastes “operator says ÅÄÖ” there, 7/7. In the lab browser, the demo’s Desktop panel does the same through the real browser clipboard and a real Ctrl+V. The local cursor (2026-10-03). Module E now reads each capture’s stream itself, frames and the cursor, as the node’s only consumer: a second consumer beside pipewiresrc lost either the first frame of a still screen or the shape (both measured). The robot’s shape becomes the surface’s CSS cursor under the operator’s own pointer, and an overlay at the robot’s position everywhere else. In CI, opsim’s desktop-cursor receives mutter’s 24×24 arrow with its pixels and the pointer where its input put it. In the lab browser, the demo’s Desktop panel shows the same arrow both ways. Found on the way: control envelopes sent before the control channel opened were dropped, which lost the cursor’s shape and the clipboard offer for every session but the first; they are now held until it opens. sharpness and per-session virtual monitors (2026-10-03). A producer that any viewer asks to keep sharp lowers its frame rate as the bitrate falls, instead of each frame’s quality (docs/16). add-monitor gives the operator a screen of their own on the robot, sized to their window: the helper’s RecordVirtual stream is that monitor’s capture, it streams like any other, and it goes with remove-monitor or the session (docs/22). In CI, opsim’s desktop-virtual adds a 1024×768 screen (answered in 19 ms), receives its frames, and removes it, 6/6. In the lab browser, the demo’s “Add a screen” shows the new screen streaming and removes it. On the mini-PC (2026-10-03). The cursor, sharpness and virtual monitors worked on the real desktop. Copying from the robot did not: module E asked mutter for the clipboard under the message’s own name, which the fixture’s wl-copy answers anyway and a GNOME app does not, and an empty blob was never sent; both fixed, and the helper now refuses a type the robot does not offer, so CI catches that class. GNOME’s screen-sharing stop left the desktop gone until the agent restarted: the agent now reports sharing: stopped (keyed on the input session closing, since an unplug also ends a capture), and any session may resume-sharing; the dashboard chooses to ask or resume at once (onRobotStop). Resuming found one more: a track returning to a live session reused its SSRC under a payloader that restarted its sequence numbers, and SRTP refused them as replays, which also hit a re-plugged monitor about half the time. Stop, resume and auto-resume verified on the mini-PC. Real monitors (2026-10-04). With the three Dells on the DisplayPort MST chain, two of them (DP-4, DP-8) streamed no frame in metadata mode and fell back to the cursor in their video, so the embedded fallback is needed on real hardware, not only for unpainted virtual monitors. Unplugging the chain crashed GNOME Shell itself (SIGSEGV after “Page flip failed: drmModeAtomicCommit: Invalid argument”, an upstream mutter/KMS bug on this MST chain, as on X11 in 2b); the desktop session restarted, the helper reconnected after 54 s, and the dashboard showed the desktop again with no one at the robot. PNG images on the clipboard both ways came the same day. 3.5 is done.

3.6, separate windows (2026-10-04). The spike (spikes/presentation-mode) closed open question #19 for the portal: an empty popup stays in its opener’s browsing context group under every COOP, where a popup loading its own page does not, and two windows render one stream. On a real two-screen Chrome it found one popup per click even with the Window Management permission, Keyboard Lock catching Alt+Tab and Super per fullscreen window, and ~15 fps per window with two fullscreen windows on two screens under X11 (a presentation cost, not the design’s). The user then chose the simplest form: “Open in separate window”, one click per window, fullscreen its own second click, no placement or permission. Built: useSeparateWindows and <DesktopView> using its own window (docs/22); a browser test opens a window, streams, clicks into the robot from it, goes fullscreen on its button and closes with the dashboard; verified by the user on two screens with the Mini PC on demo.fjarr.io, and in Firefox. 3.6 is done.

3.7, the X11 kiosk (2026-10-05). Backend A runs in the agent’s own process (docs/23), with robot-sim in fixture mode as its CI fixture (make x11-e2e: see, drive, hot-plug, clipboard, cursor). The fixture found four things: Xvfb’s root cannot grow, so it starts 3840 wide and carves monitors out of it; it sends no RandR event for those monitors, so the module re-reads them every 500 ms (a 2 s timer missed the 2 s hot-plug budget); it resets when its last client leaves, which dropped the grant (-noreset); and ximagesrc needs the X server’s IPC namespace or captures stale frames. The package fjarr-desktop-x11 carries fjarr-x11-session (the grant and the output layout, from the kiosk session’s autostart entry), and setup desktop --x11 links that entry and points the agent at the display, leaving the display manager to the operator; the install test runs both and the undo. The clipboard (3.7c) uses X selections with INCR above 256 KiB; its first runs stalled transfers until their timeout, because events that a round trip outside the event handler had read sat in Xlib’s queue, where a descriptor watch never sees them; the module’s event source now checks that queue too. The fixture also stages an owner that never answers and text over the limit. On the mini-PC (2026-10-05) with LightDM’s automatic login into Openbox, from the packages and setup desktop --x11: the three Dells on the DisplayPort chain and the ghost laid out left to right, every monitor streaming with the same wire id it has under GNOME, input with åäö, the local cursor, the clipboard with text and images both ways, and an unplug and re-plug laid out again. It found five things, all fixed: an unplugged Dell reported as a nameless monitor for ~3 s (its output kept its CRTC without its EDID); Xorg making the ghost primary (fjarr-x11-session now applies docs/26’s rule); the ghost, moved into the gap, restarting its capture and re-offering 1 ms after the previous answer (a same-size move now keeps its capture); the dashboard then answering no offer again until it was reloaded (one stuck offer held every later one; each new peer starts fresh); and the hosted demo’s operators dropped every 2 min 5 s by Cloudflare’s idle limit on a quiet signaling socket (the server pings every 30 s). The X server itself stalls ~4 s while the chain re-probes, a driver limit as in ADR-0006. Restoring GNOME found three setup bugs, fixed the same evening. 3.7 is done.

3.8, a desktop with the agent in a container (2026-10-06). Erik chose a host package: the desktop’s host side moved into fjarr-desktop-session (helper, user unit, group, GDM watchdog; no agent), the setup tool into fjarr-setup, and fjarr-desktop-wayland keeps the module and recommends the session package, so a robot’s apt install is unchanged. On the host, fjarr-setup setup desktop --container does the host’s part and prints the desktop uid and group gid; in the container, setup desktop --helper-uid --helper-gid writes them (docs/26). The image has a desktop-wayland stage. make compose-gate now ends with a desktop beside the reference file’s agent: the host’s helper connects through the bind-mounted socket and opsim’s desktop-see passes. The gate found the module needed pipewire-bin’s client.conf, a package that brings GPL readline: the module now ships its own client configuration over libpipewire-0.3-modules. A second install test proves a container host (the session package alone). An X11 kiosk with the agent in a container is specified and not built. 3.8 is done; every M3 slice is.

The nightly red, explained (2026-10-17). The unattended-access nightly on the mini-PC was red every night from 10-08. Two harness bugs: opsim ran the X11-only mode change without a plug and failed by construction, and input-to-photon counted presses from a frozen picture as 0 ms samples, a false pass on 10-17 (both fixed in 5b864a3). Ubuntu’s upgrade wizard opened on every boot (8735c52), verify judged a late prepare stale, and nothing reported a red night (91a550d). Under them was a real defect, found on the machine the same day: since the local cursor (232dbbf), mutter 50.1 scans a fullscreen GPU window directly out and records nothing into the shared-memory stream the agent reads, so the operator saw a frozen picture behind green counters. Erik chose DMA-BUF capture without a copy into VA (ADR-0041); mutter fixed the shared-memory side in 50.5, which Ubuntu 26.04 does not have (open question #49). The M3 gate waits for that change and a green night with the new scanout check (docs/15). The change is built (2026-10-17): the import list, module E’s DMA-BUF reader with SHM as the fallback, the device-memory head with one importing vapostproc, the frame counts in /sources, the hidden pointer, and fjarr-opsim desktop-scanout in the unattended verify. CI covers the SHM path and the unit tests, a VA workstation the DMA-BUF chain into vah264enc; mutter’s DMA-BUF stream inside the agent is first exercised on the mini-PC.

Verified on the mini-PC (2026-10-18, by hand, build c5dba78). The agent negotiated DMA-BUF AR24:0x0200000000000901, into the encoder without a copy through the device-memory head, and the unattended-access test passed whole for the first time since 10-03: desktop-scanout followed 10 of 10 changes, 9 of them under direct scanout; a window hiding the pointer gave a hidden shape; input-to-photon took 10 real samples (p50 200, p95 202 ms on the robot, not a number of record). From the dev machine over the LAN, the same build: p50 49 / p95 73 ms over 50 presses (docs/16, M3 gate review). The night’s own prepare had failed on a truncated artifact download over the machine’s Wi-Fi (fixed in 91455a3), and NetworkManager on the mini-PC now ignores the ZED Box’s USB network, whose “Connection failed” banners stopped scanout.

fjarr.audio (robot microphone downlink, push-to-talk uplink through the pre-allocated transceiver, a hard volume cap in the agent’s config, uplink only when the grant says talk, audited) and desktop audio on fjarr.desktop (the sink monitor, measured unattended in the M2 spikes). The web side exists already: usePushToTalk, useAudioTrack, <AudioSink>, the audited audio-uplink event and the answer direction taken from the offer. What is left is the agent: the capability, the Opus pipeline, the recvonly transceiver and the output. Open questions #3 and #17 were settled on 2026-10-11 (Erik): hearing the robot’s microphones needs params.listen in the grant and talking params.talk; push-to-talk is the default and open mic the operator’s choice, under the same grant. Gate: the fjarr.audio acceptance criteria (docs/06).

M3.5 goes before M4 (Erik, 2026-10-11). Slices, planned the same day:

SliceWhatDone when
3.5.1 Hear the robotfjarr.audio’s downlink: a configured microphone (an ALSA device, docs/23; a test tone in CI), Opus, an audio track offered on demand, only to a session granted listenthe dashboard plays the fixture’s sound within the docs/16 budget, in CI; a session without listen is offered none
3.5.2 Desktop audiothe robot desktop’s sound as a track on fjarr.desktop (docs/23): on GNOME the session helper hands the agent a PipeWire connection narrowed to the default output, as it hands the screen, and the capture runs only while someone listens; on X11 the kiosk’s session program hands it over the same way (ADR-0034)a sound played on the fixture’s desktop is heard in the dashboard; a change of default output keeps it playing (the stop with the sharing: 3.5.4)
3.5.3 Talk to the robotthe agent’s pre-allocated recvonly audio transceiver, decoded and played on a configured output with a hard volume cap; talk enforced by the agent; talk-start/talk-stop gate it (docs/08, docs/23)push-to-talk reaches the fixture’s sink; releasing it stops the audio within 200 ms; audit events recorded; a session without talk gets no uplink
3.5.4 On the mini-PC (with Erik)the webcam’s microphone, a speaker for push-to-talk, and the robot desktop’s sound (the YouTube test of 2026-10-04)each heard or played on the real machine; the desktop’s sound stops with GNOME’s stop-sharing and returns on resume

3.5.1, hearing the robot (2026-10-11). The agent’s side is e419f71: a microphone in fjarr.audio’s config (alsa or tone), Opus at 32 kbps in 20 ms frames with in-band FEC, one tier, and the tracks added in session_attached only for a session whose grant says listen, so the filter costs one check per session. opsim’s audio scenario decodes the tone (RMS above silence) and audio-unlistened proves no track is offered. The demo dashboard’s Microphones panel (53ed27d) mounts an <AudioSink> while Listen is ticked; its lab browser test requires the audio clock to advance and the track to stop streaming when unticked. Not measured: audio latency, as docs/16 has no audio budget yet; 3.5.3’s push-to-talk sets the first one (200 ms to silence). 3.5.1 is done.

3.5.2, the desktop’s sound (2026-10-12). On GNOME the session helper watches the default output and hands the agent a PipeWire connection narrowed to it (a4a70c7 spec, 33fd7d5); measured first in spikes/desktop-audio, the connection hears the speakers and never the microphone, and fixture-helper-check keeps proving it. On an X11 kiosk the session program does the same, as the kiosk’s own account (ADR-0034, Erik’s choice over a shared PulseAudio socket; f63855b), and setup desktop --x11 records that account. The capture runs only while the producer’s pipeline runs. opsim’s desktop-audio passes on both fixtures: silence, then the tone about 300 ms after it starts, still heard across a change of output, no capture before enable and none after the grace. The dashboard’s Sound toggle plays it in the lab browser. Building the scenario found two defects in the tests themselves (a release check that could pass on a capture not yet open, and a helper check that could close the descriptor it tested), both fixed. The stop with GNOME’s stop-sharing cannot be staged in the fixture (no GNOME Shell) and moves to 3.5.4 on the mini-PC. The mini-PC’s nightly then found what the clean-install tests could not: moving the PipeWire client configuration into fjarr-agent broke the upgrade over an earlier build of fjarr-desktop-wayland, which still owned the file; fjarr-agent now Replaces it, and the upgrade was replayed in a container. 3.5.2 is done.

3.5.3, talking to the robot (2026-10-12). The agent offers a session granted talk one recvonly audio m-section (e43f7df spec; 756b2e3 agent, 968d68c web). The core decodes it per session through a new SessionContext::add_inbound_audio, and fjarr.audio plays one session at a time through a speaker pipeline that exists only between talk-start and talk-stop, under a fixed volume cap, sending the talker talk-level; every start and stop is an audited audio-uplink session event. opsim’s audio-talk against the demo robot: nothing of the audio sent before talk-start is played, 50 frames a second after it, a full-scale source peaks at −6.0 dBFS under the 0.5 cap, and the release is answered in about 2 ms with nothing played after it; audio-untalked gets no uplink and capability-denied. The dashboard’s “Hold to talk” plays the lab browser’s fake microphone at the robot. Building it found that an answer naming no SSRC for its uplink lets webrtcbin route the stream to the BUNDLE’s first transceiver and stall the session; an unmatched stream is now sunk. Not measured: mouth-to-ear latency (docs/16, < 250 ms) and echo cancellation with a real speaker and microphone, both for 3.5.4. 3.5.3 is done.

3.5.4 on the mini-PC, first session (2026-10-12, with Erik), paused.

  • The desktop’s sound: heard, but muffled and speeding up and slowing down. Fixed (b46f26e): opusenc carried each PipeWire buffer’s arrival time into the RTP clock (about 60 of 150 packets uneven in the fixture, none after perfect-timestamp), and voice tuning at 32 kbps suits no music, so a source now declares AudioContent::Music (64 kbps). Not yet re-heard.
  • The webcam’s microphone: not working, open. Two problems found. (1) The desktop session’s PipeWire holds the ALSA capture device whenever a desktop application records (GNOME Settings’ input meter did), and the agent then gets “busy”: the 3.5.1 design (ALSA directly) and a desktop on the same machine compete for one device, which needs a decision (the microphone through the session helper as the desktop’s sound is, or a WirePlumber rule reserving it for the agent). (2) With the device free, the agent’s alsasrc still fails with “No such file or directory” on plughw:CARD=Webcam,DEV=0, while arecord with the same device as the agent’s account works: something in the agent’s process (environment, ALSA configuration, the card having moved from 1 to 2 at a reboot) to debug next.
  • Stop sharing and talking (no speaker attached: the default output is auto_null) are not tested yet.

Vendor cameras and the design-partner demo {#vendor-cameras}

Section titled “Vendor cameras and the design-partner demo {#vendor-cameras}”

The first vendor camera packages (ADR-0020), delivered through the repository and the driver catalog, and the first design-partner demo (docs/03 GTM). Scheduled when the design partner’s hardware is known, rather than holding the desktop gate. The lab’s planned Raspberry Pi 4/5 (Pi cameras) and Jetson (ZED) machines are the likely first targets (docs/12). Gate: the design partner installs from the repository with setup, adds their camera with drivers install, and writes no config by hand.

fjarr.files (chunked/backpressure/resume — greenfield); fjarr.telemetry with the ROS 2 TelemetrySource adapter and store-and-forward; fjarr.logs peer mode (live tailing with server-side filters and backpressure — the support tool design partners will ask for); the sensor-transport spike on a simulated depth/lidar source that settles depth-as-video vs stream class per the docs/06 matrix (the ADR-0018 chunker/reassembler is implemented only when a capability actually picks the stream class — docs/13 KISS); the cheap conventions: camera/snapshot, telemetry/alert, files/list, haptic events. Gate: 1 GB resume-after-kill with verified hash; bulk/interactive isolation within budget; telemetry replay after outage; logs tail from robot-sim with a filter; spike report attached to open question #16.

fjarr.files is designed for the drive of ADR-0029 from the start: listings with metadata, stat, byte-range reads, and mkdir, delete and rename. After M4’s gate, fjarr-connect serves the robot’s allowed paths as a WebDAV folder. Its gate: Nautilus, Finder and Explorer mount it, list a directory, open a file and save it back, and are refused outside the allow-lists.

Acting as another account (open question #42): the terminal and the files act as the agent’s own unprivileged account today, which can barely do anything on a robot (mini-PC, 2026-10-04). M4 starts with a spike on the mini-PC and an ADR: a shell and file access as a configured account (robot) without making the agent privileged. The leading option is systemd’s own path (run0 --user= or machinectl shell) allowed by a polkit rule the package ships, limited to the agent’s account and the configured target; the spike settles whether polkit can restrict the target account. Its gate: the dashboard’s terminal opens a login shell as robot on the mini-PC, fjarr.files reads and writes as robot, and the agent still runs as fjarr with no new privileges of its own.

M4.5 — Direct access: the network tunnel

Section titled “M4.5 — Direct access: the network tunnel”

fjarr.net per docs/27: the persistent tunnel interface created by fjarr-agent setup, the packet pump on fjarr:stream:fjarr.net, derived addressing, the two policy rules that make isolation structural, and the audit trail. Plus fjarr-connect (ADR-0024), Fjarr’s first non-browser operator and its first macOS artifact, shipped in fjarr-tools. Plus the documented Cyclone DDS configuration and setup’s offer to write it. Plus the discovery half, without which the CLI is a debugging tool rather than a product: the optional operator API on the customer’s backend, the FjarrCliLogin handoff component in @fjarr/react, and both implemented in demo-backend and demo-dashboard as the reference.

Its only hard dependency is the M2.5 packaging and installer, because the interface must be created before the robot’s software starts — it may be pulled ahead of M3 or M4 if a design partner needs field debugging sooner, and the spike already de-risked the unknowns.

Pulled forward (planned 2026-09-25, re-split 2026-09-26). The installer is the only part that needs packaging; in the lab, compose creates the device the same way the installer will, so everything else is buildable and testable now. The plan was two slices; after 4.5a landed, what remained was too large for one — six gate items, a third WebRTC implementation, and a lab that cannot yet run ssh, scp or ros2 at all. Split into five, each sized for one session and ordered so every slice is provable when it lands:

  • 4.5a — the agent side. Done 2026-09-25. The core could not route inbound stream-class data at all: it assumed every binary channel was a bulk one, so a tunnel packet was cut at the wrong offset, matched no capability, and was dropped and counted. That came first — one parse of the label for both classes, a sender for the stream class, and registration refusing a framed stream channel so ADR-0018’s chunker stays unbuilt until something needs it. Then fjarr.net: attaching to the persistent device with no CAP_NET_ADMIN, the packet pump, derived addressing, the two policy rules, tail-drop, and link-stats — which needed the extension API’s second amendment, SessionContext::every, because the event is specified per second on an idle link too. make tun-up plays the installer in the lab, and fjarr-opsim --scenario tunnel carries an HTTP request to the robot’s own introspection endpoint over the link. Gate met: GET http://<robot tunnel address>:7381/stats -> 200 over a real data channel; packets addressed into the robot’s LAN refused and counted; tail-drop unit-tested against a refusing channel, including that what was dropped is gone rather than queued.

  • 4.5b — gates worth trusting. Not tunnel work: CI’s lab job is red on main and two suites are additionally red on the development machine, and the next slices measure media. In CI, opsim toggle intermittently fails toggle-keyframe: 19 of 20 enables produced a keyframe and a decoded frame, worst 2912 ms on 2e5759f, while the same assertion passed on e84ef64. One enable in twenty missing its budget is a tail-latency question, not a broken feature, so the slice has to decide whether the budget or the assertion is wrong rather than hunt for a bug. Reading that job’s log also turned up a GLib critical on every session close that had opened a pty — an fd watch released twice, fixed in cd81195 with the regression test the seam never had; it was a passenger in a job failing for another reason, which is what a red gate costs. opsim hotplug fails deterministically when it runs after smoke in opsim-all and passes standalone — determinism that specific is usually findable. ratecontrol.spec.ts reports 1.1 Mbps on a clean link where it asserts 3, with VA-API active and no qdisc left behind. Both reproduce with 4.5a’s changes stashed, so neither is the tunnel’s. All three come first because question #23 — whether a scp starves the camera — is answered by reading local media numbers, and numbers from a suite that fails for unknown reasons answer nothing. Gate: both green locally, or the cause understood and the assertion made honest about it — the precedent is opsim netem wifi-ok, which records loss rather than asserting it because the receiver over-reports under jitter. And a standing question for the slice: a critical in a robot’s log should fail the run that produced it, not wait for someone to read it.

    In progress 2026-09-26. The local rows are answered and neither was the agent’s. hotplug fails only under CPU load — reproduced with 20 spinners and the identical signature, while smoke-then-hotplug is green on an idle machine, so “deterministic after smoke” was the load of my own builds; the frame assertions now report the robot’s view of the tracks on failure (why_no_frames), which is the evidence that was missing. The three-viewer rate-control test had three defects, all in the rig: it impaired the direct path before the viewer connected, so ICE moved to the relay and the session was never congested (14 MB to coturn against 3 packets impaired); it compared steady state against the encoder’s opening overshoot, calling convergence to the 4 Mbps target a 15–20 % regression; and its demotion budget came from a design estimate of 8–9 s against a measured 11.9–20.4 s. Fixed by choosing the impaired path (relay + netem toward coturn), judging the clean viewers against the configured target, and setting budgets from measurement: 5 of 5 runs green, and the full loopback + stack suite 52/52. Measurements folded into docs/16 and the harness rule into docs/25. CI then failed on a third thing, which is the slice’s own subject: opsim-all’s retry for a post-verdict SIGSEGV in the simulator’s teardown had never once fired, because a nested $(MAKE) reports its own exit code 2 and the guard tested for 139 — measured directly, 139 from docker compose exec against 2 through make. The loop now runs the command itself, and it retries only when the scenario printed a clean verdict before dying; a crash before the verdict is not retried.

    The standing question is answered: make agent-log-gate fails a run whose robot logged a GLib or GStreamer critical, and agent-log-gate-selftest proves it can fire — validated the hard way by re-introducing the cd81195 double release, watching the terminal suite pass 4 of 4, and watching the gate refuse the run with the three criticals CI had been carrying (docs/15).

    CI’s toggle-keyframe is answered too, and it was not a defect: keyframe requests are throttled to one per second per producer tier and deferred inside that window (docs/23), while toggle cycles faster than that by design, so an enable can wait out the throttle and then a GOP. Worst per run measured at 757-949 ms here against ~2.9 s on CI’s 2-core software encoder, which put one enable in twenty over the scenario’s 4 s patience. The patience now follows the mechanism, the assertion stays categorical, and the floor is recorded in docs/16. All three rows closed; every defect was in a gate or a rig, none in the agent. Split again 2026-09-26, before starting. What was one 4.5c carried the whole rig plus four gate items; reading the specs through, the rig and ssh/scp are one provable increment and ROS 2 is another, with its own spec work (the Cyclone file is promised in docs/27 but has never been written down). Two slices, and the letters after them shift:

  • 4.5c — the robot’s services, and a link something else can use. The lab carries packets and nothing else: no sshd, one robot, and a tunnel that only fjarr-opsim’s own assertions ever touch. None of this needs fjarr-connect — the simulator already pumps a tunnel, so the robot side can be proven before the Rust client exists, as 4.5a proved the capability before the client. The services the tunnel is for live in sidecars sharing the robot’s network namespace rather than in the agent’s image: that is what a real robot looks like from the far end of a link (sshd and ROS 2 are separate processes on the same network stack, not part of fjarr-agent), and it keeps ROS 2 and an ssh daemon out of a container whose job is to be the agent. Plus a second robot with its own id, ready for 4.5e’s isolation regression. And the piece that turns a link into something usable: fjarr-opsim --scenario tunnel --exec <cmd> runs a command with the link up and FJARR_ADDR in its environment, which is a rehearsal of fjarr-connect robot -- ssh robot@$FJARR_ADDR (docs/27). Gate: ssh login and a hash-verified 1 GB scp over the link, and question #23 measured — a camera streaming while the scp runs, which is the first time anything has pushed enough through a link to disturb the video beside it.

    Done 2026-09-26, with one item blocked. ssh reaches a shell on the robot in ~130 ms, reliably; a 1 GiB scp transfers and verifies its sha256 end to end at 338 Mbps alone and 190-236 Mbps beside a streaming camera. #23 is closed: the camera is unaffected by a saturating transfer (31-33 fps, no lost frames, longest gap 55-70 ms against a 48-51 ms baseline), and the transfer pays about 35 % of its throughput, so no separate PeerConnection for bulk is needed on these grounds. What is blocked: that transfer stalls in roughly 40 % of attempts, at the onset of the flow, with every drop counter at zero on both ends — new question #28, with video, transfer size, the MTU check, the channel watermark and the operator’s device writes all ruled out. CI asserts the shell and records the transfer, because a gate that is red two runs in five teaches people to ignore red. The M4.5 gate cannot claim a reliable scp until #28 is settled, and settling it needs SCTP-level evidence rather than more black-box runs.

  • 4.5d — ROS 2 over the link. ROS 2 as sidecars on each end’s namespace, ros2 topic list against the robot with Fast DDS unconfigured, the Cyclone DDS file written into docs/27 for the first time (the spike measured it; the spec only promises it) and fjarr-agent net setup’s offer to write it, and the three ordering facts of docs/27 as a regression — a participant created while the agent is detached does not advertise the tunnel address, one created while attached does, and it keeps advertising across an agent restart. Those three are the measured facts the whole lifecycle rests on, and nothing has ever re-checked them.

    In progress 2026-09-26. ros2 topic list and ros2 topic echo both cross the link (make tunnel-ros), from a ROS 2 sidecar on each end’s namespace, with docker/lab/dds-isolate.sh removing every direct path first — without that the two ends discover each other over the lab’s own bridge and the test proves nothing, which is the trap the spike fell into. Getting there needed ADR-0026: docs/27’s isolation rule said a packet’s destination must be this end’s own tunnel address, which a multicast destination never is, so the tunnel dropped every DDS discovery announcement and ROS 2 over the link could not work as specified. The rule now admits multicast on the strength of its source, the source rule carries the isolation guarantee alone, and docs/08, docs/10 and docs/27 say so. Stock Fast DDS then needs no configuration, exactly as claimed. The three ordering facts are now a regression (make tunnel-ros-ordering): a participant created while the agent is attached reaches the peer, one created while it is detached never does, and a working one survives an agent restart. The lab agent runs under a supervisor so that third fact is testable at all — restarting the container would take the interface with it, which a real robot does not do.

    The Cyclone file now exists (docker/lab/cyclonedds-tunnel.sh, and the exact XML is in docs/27, which had promised it since the spike) and its effect is measured: stock Cyclone leaves ros2 topic list hanging with no output, the file fixes that, and discovery across the link still does not complete — 78 discovery packets reach the robot and its Cyclone does not answer usefully. Cyclone is therefore documented as unverified over a real link rather than claimed as supported on the spike’s word, and Fast DDS is the verified path. fjarr-agent net setup is deliberately not built yet: its job here is to offer to install that file, and offering an unverified one is worse than offering nothing. Both now sit in question #29 rather than holding the slice open: what is left is Cyclone’s own locator handling on a point-to-point interface, which a design partner who needs Cyclone is the right trigger to spend more on. 4.5d is otherwise done 2026-09-26.

  • 4.5e — fjarr-protocol and fjarr-connect (ADR-0024). The shared signaling types move into their own crate here, when a second consumer exists: the protocol module is 217 lines needing serde and one UUIDv7 call, while the signaling crate carries axum, hyper and the HMAC stack that an operator CLI has no business linking. (This plan said “only serde”; extracting it turned up the uuid call, which is equally runtime-free.) Then the client itself — signaling, one webrtc-rs peer connection, one stream channel, no media at all, the TUN/utun device with a /32 route per attached robot, --grant <jwt>, a link that lives in the terminal that started it, and -- <command> with FJARR_ADDR in its environment. Several robots at once is what the single-interface design exists for, so address collision detection and the two-robot isolation regression (docs/15 safety class, never removed) land with it rather than before. Gate: the 4.5c end-to-end repeated through fjarr-connect instead of fjarr-opsim; two robots attached at once provably unable to reach each other in either direction; a colliding pair refused by name with the address line that fixes it.

    Started 2026-09-26. fjarr-protocol is extracted, and fjarr-connect exists as far as signaling: against the real server it takes a --grant, is issued a session with TURN credentials, and reads the robot’s offer — and an offline robot fails with the server’s own robot-offline rather than a timeout, which is what docs/27 asks for. --dry-run is the only mode it has, and it says so rather than pretending: the peer connection, the TUN device and -- <command> are next. docker/lab/mint-grant.sh mints a lab grant so nothing needs Node to get one.

    The link carries IP traffic, 2026-09-27. fjarr-connect <robot> -- <command> answers the offer, opens both channels, asks fjarr.net for the link, attaches the operator’s interface with a /32 route for that robot, pumps packets under the same two policy rules the agent applies, and tears the route down when the command exits: ssh, an HTTP request and a hash-verified 1 GiB scp all run over it. Two defects in already-reviewed code came out of it, both in the slice-4.5e review: the client’s ICE had to trickle in both directions before any link came up at all, and the agent had been aborting inside glibc’s allocator in every saturating transfer because the packet pump’s fd-watch callback fell off the end of a -> bool function — one cause of #28, fixed, measured A/B, and now a build error rather than a warning nobody read.

    Gate met 2026-09-27, with macOS honest rather than claimed. make tunnel-isolation is the docs/15 safety-class regression: two robots on one interface, each reachable from the operator, neither reachable from the other with a route forced into its tunnel and verified there — written the obvious way it had passed while measuring the lab’s own bridge, which is slice 4.5d’s mistake repeated. make tunnel-collision pins the second robot to the first’s address and the operator refuses the pair by name. The client moves 1 GiB at 246-253 Mbps against opsim’s 264. Two more spec promises turned out to be unkeepable and were fixed rather than reworded: setcap could never have worked while the client shelled out to ip (a file capability is not inherited by a child), so the interface, its address and its routes are now set over netlink in-process; and “several robots at once” needed --grant per robot, since a grant names one robot. macOS is written, type-checked for aarch64-apple-darwin in CI, and recorded in docs/04 as never having run on macOS hardware. Follow-up, 2026-09-27, before 4.5f: warnings are errors on every shipped target; the ROS ordering regression runs nightly; link-stats carries abandoned from a log hook on GStreamer’s sctpassociation category; and #28 is narrowed, not closed — fjarr-connect moves 1 GiB 5 of 5 beside a 30 fps viewer where opsim is red about half the time, so what wedges is a usrsctp-to-usrsctp association and the failing operator is a lab tool. Decision: 4.5f starts now; reading both ends of a wedged association — which says whether it is opsim’s bug or the robot’s — comes after it, and the M4.5 gate’s scp claim is made through fjarr-connect, the product, and says so. The slice-4.5e review carries the three lab fixtures that had to be made to fail first.

  • 4.5f — discovery and login. Without this the CLI is a debugging tool rather than a product: the optional operator API on the customer’s backend, the FjarrCliLogin handoff component in @fjarr/react (docs/21), both implemented in demo-backend and demo-dashboard as the reference, and the CLI’s own login, list, picker, --ssh-config and the short-code path for a host with no browser. Gate: fjarr-connect login through the demo dashboard, then a list, a pick and a connect with nobody typing a robot id; the same on a host with no browser; every open and close in the audit log as session.started/session.ended with fjarr.net among the capabilities.

    Gate met 2026-09-27, for the code shape end to end and the loopback shape in halves (docs/15). The code path needed a spec first — three operator endpoints docs/09 did not have — and the slice-4.5f review records what the rest found: a login URL derived from the wrong host, two component tests passing on a matcher that did not exist, and session.ended never naming its capabilities.

macOS is ADR-0024’s committed second platform and there is no macOS runner, so 4.5e ships it cross-compiled and hand-checked, with the matrix honest about that until M5’s packaging work says otherwise.

Gate: docs/06 fjarr.net criteria — ssh and a hash-verified 1 GB scp to a robot behind carrier NAT; ros2 topic list against it with Fast DDS unconfigured and with the documented Cyclone file (amended out as #29 at the gate, and met the same day once the check stopped racing the ROS 2 daemon); fjarr-connect login through the demo dashboard followed by a list, a pick and a connect without anyone typing a robot id, and the same on a host with no browser; two robots attached at once provably unable to reach each other; the agent upgraded without restarting the robot’s ROS stack; every open and close in the audit log.

Met 2026-09-27 — make m45-gate, every claim through fjarr-connect relay-only with the direct path removed; the evidence, criterion by criterion, is the M4.5 gate review. Carried out of the milestone: #28 for the lab’s usrsctp-to-usrsctp operator, and #29 for Cyclone — closed the same day.

Next for the native client (decided 2026-09-27): fjarr-connect shell, which attaches the operator’s own terminal to the robot’s fjarr.terminal pty with no tunnel and no privilege (docs/27). It is small: the terminal protocol is unchanged, and it only needs raw mode, resize and a grant that carries the terminal. Gate: an interactive shell, a resize seen by the robot’s stty size, the shell’s exit status as the client’s, and the local terminal restored after a killed link. Met 2026-09-28 (make connect-shell, from a binary with no file capability): all four, plus the terminal restored after a normal exit and a SIGTERM, and a grant without the terminal refused as capability-denied. The operator API gained an optional capabilities narrowing so login’s grant carries the terminal alone (docs/09). macOS and Windows are type-checked, not run (docs/04).

Device enrollment/identity, multi-tenancy, ephemeral TURN at scale plus TURNS/TCP-443 fallback, usage metering, the first OpenAPI-generated SDK (TS), multi-operator presence (session-peers from the ownership leases), access governance for the tunnel (time-boxed and approval-gated net grants, per-user policy, audit export — the paid half of docs/03), Cloud alpha implementing the same ADR-0015 contract as the sidecar. Gate: a design partner integrates against Cloud using only public docs; security review of docs/10 items; metering visible in webhooks; two operators on one robot see each other and hand over control.

Third-party plugin authoring docs + template repo; ABI/compat promises (docs/05); clipboard images/files; multi-monitor polish. Gate: an external developer (not us) builds a toy capability from docs alone.

M7 — Fleet observability (flagship paid tier)

Section titled “M7 — Fleet observability (flagship paid tier)”

Runs after M8 (Erik, 2026-10-12): M8 reports each robot’s release version and update outcomes, and M7’s metrics and errors are keyed on that release version (ADR-0036).

Versions, host + user metrics, error rates; ingest + aggregation; fleet views in @fjarr/react; alerting in Cloud; fjarr.logs backend mode feeding the same store. Gate: docs/06 observability criteria; 10-robot sim fleet demo.

Designed 2026-10-12 with Erik (docs/32, ADRs 0035, 0036 and 0037; ADR-0016 superseded). M8 runs before M7 (Erik): it reports just enough of M7 itself, each robot’s release version and its update outcomes, and M7’s statistics are keyed on that version from the start. One signed release version pins the whole robot; fjarr-update orchestrates fetch, readiness, prepare, install, health, peripherals and commit; content travels as HTTPS chunks under a budget per time interval; campaigns are promoted stage by stage by a person.

Each slice is usable on its own and tested on the lab’s hardware: a Stereolabs ZED Box Mini (Jetson Orin NX/Nano, NVMe) and the x86 mini-PC.

SliceWhatDone when
8.0 Hardware spikeson the ZED Box Mini, a meta-tegra A/B image with the bootloader capsule and a jetpack-nixos A/B image; on x86, a minimal Ubuntu A/B image and boot counting with systemd-boot or GRUB; SWUpdate vs RAUC vs systemd-sysupdate per recipe; the chunking parameters and real delta sizes for typical updates (kernel, CUDA, an ML model), by chunks and by precomputed binary diffs (xdelta, bsdiff); the licences of Stereolabs’ board files (#45)a power cut mid-install boots the old slot on each image; the measurements choose each recipe’s applier, the chunk parameters and the scope of 8.3b (recorded in docs/32)
8.1 Release formatthe manifest (components, versions, requires, rollback classes, hardware variants), TUF-style signing with company-held and Fjarr-managed keys, the release counter and downgrade authorization; fjarr bundle, check, push; the agent’s registration carries its release and version vector (docs/08)fjarr bundle → push → verification on a robot succeeds; a flipped byte in a chunk or the manifest, an unknown or expired key, and a lower release without authorization are each refused with a named error; the dashboard shows each robot’s release and vector
8.2 The updaterfjarr-update and its state machine, the run state on /data, run logs (steps, statuses, streamed output, the local journal and backfill after a reboot; the messages in docs/08), install from a local bundle or USB, the apt applier for today’s Ubuntu robotsa release installs on an existing Ubuntu robot and its run log reads step by step, live and after a reboot; a release from USB installs offline and its log uploads on reconnect
8.3 TransportHTTPS chunk fetch with resume, the budget per interval in the site’s time zone, the rate cap from the agent’s bandwidth estimate, space checks, cache cleanupon a link shaped to 5 Mb/s with drops every few minutes, a 1 GB release arrives across 3 days within a 400 MB daily budget, while a 2 Mb/s video session keeps ≥ 90 % of its bitrate with no freeze over 1 s
8.3b Precomputed deltasfor app components (container images, binaries, packages), the server computes file-by-file diffs (bsdiff or zstd --patch-from per changed file, chosen per file) from each source release still in the fleet; the robot fetches the diffs matching its release and falls back to chunks per file; the file list travels as a diff against the robot’s own; download prediction counts diffs. OS slots and models stay with chunks (8.0’s measurements)an application container’s patch release fetches about what 8.0 measured (1.5 MB instead of 21 MB by docker pull for the Caddy update), the robot reconstructs the identical content, and the bytes fetched match the prediction
8.4 Yocto and NixOS on the ZED Box Minione ZED Box Mini board package; meta-fjarr and the jetpack-nixos flake built on it, side by side; their appliers; health (reachability, probes, the confirmation window); fjarr provisionnightly in the lab, for both: an update, a failed health check that rolls back, a power cut mid-write that recovers; a trial kernel that hangs before userspace recovers without a person (Erik, 2026-10-17: Q8, #48); neither is done until both pass
8.5a Ubuntu A/B on x86the mini-PC board package, the x86 layout, the Ubuntu A/B image and its applier: SWUpdate and EFI Boot Guard (ADR-0040), from 8.0’s spikes/ota-x86/image-ebg, with a trimmed SWUpdate build and the loader pinned until Ubuntu ships EBG 0.20 or laterthe same nightly tests on the second mini-PC; the image carries a GNOME desktop that the M3 desktop tests pass on
8.5b Yocto and NixOS on x86the Yocto layer and the NixOS flake on UEFI x86the same nightly tests for both
8.6 Readinessthe local API (facts, holds, prepare actions, probes, on-site confirmation), the conditions and their reasons, the update hold on motion, not-ready-to-move, hook phases, /data snapshots, ceilings and deadlinesan update waits for the charger and says why; prepare times out cleanly; take-control is refused during an install; a rollback restores migrated data
8.7 Peripheralsthe handler contract; u-blox configuration, a CAN flasher, and router configuration with a router-side revert, on the bench, MIT-licensed (ADR-0038)one release updates the OS, the GNSS configuration and an MCU in order; a router configuration that cuts the uplink reverts by itself
8.8 Campaigns (Cloud/paid)stages and promotion (optionally spread across hardware variants), batching, abort; the fleet’s policy (auto, ask-end-user), overridable per campaign, and deadlines; the security channel; campaign YAML; the run grid; each stage against the rest of the fleet (success, rollbacks, reboots, disconnects); <UpdatePrompt>one robot, then several, then the rest across simulated robots and the box; an abort midway; a robot’s owner schedules tonight in the demo dashboard
8.9 Build caches (Cloud/paid, candidate 2026-10-17)a Fjarr-hosted Yocto shared-state mirror and Nix binary cache for the board packages (ZED Box Mini, x86) and each pinned release, so a customer’s first OS build downloads the toolchain and the board’s kernel instead of compiling them (docs/03); after 8.4 and 8.5ba fresh CI machine builds the ZED Box Mini image from the cache in minutes, compiling only the customer’s own layers; the recipes still build without it

Preconditions, before 8.0. The ZED Box Mini and a second x86 mini-PC (Erik’s, 2026-10-12) join the lab as nightly runners, the Jetson with USB recovery access. The x86 recipes are reflashed nightly on the second mini-PC, so the first keeps its M3 desktop nightly untouched. Every A/B machine is powered through a network-controlled socket with a local API (a Shelly Gen3 plug, Plug S or Plug M, switched over HTTP from the runner, password-protected, its draw read back to confirm the cut) and set in its firmware to power on when power returns, so power cuts are tested unattended.

8.0 status (2026-10-17). Started on x86, off the machine: the Ubuntu A/B test image, its applier, the rescue system and the hands-off reflash pass every case in QEMU except a real power cut, and two builds of one release are byte-identical (docs/32). The first week-apart updates are measured (docs/32): 64 KiB chunks, and no case yet for precomputed diffs on Ubuntu. The NUC (the second x86 machine, 192.168.10.177) has its Shelly plug: a power cut brings it back in 33 s, the rescue UKI boots once by BootNext and returns (three of three), its firmware watchdog resets a hung kernel by itself (53 s), and the rescue’s dead-man returns it unclaimed. With Erik’s go it was rewritten to the A/B layout over the network, and updates, rollback and a failed health check work on it as in QEMU. Sixty power cuts during the install, after set-next and during the trial boot all came back by themselves on the expected slot (2026-10-15): the gate is met for the Ubuntu A/B image on x86 (docs/32), and the ESP is now mounted only on demand and the rewrite runs eight times faster. The applier is chosen: systemd-sysupdate with systemd-boot, RAUC with GRUB compared on paper (ADR-0039). Container, CUDA and model updates are measured with file-by-file diffs (docs/32): 64 KiB chunks hold, and 8.3b is scoped to file-by-file diffs for app components. SWUpdate with EFI Boot Guard was then built and tested the same way (docs/32): one try instead of three, but the only design that recovers by itself from a kernel that hangs early. Erik chose it for one engine across the Jetson and x86 recipes (ADR-0040, superseding ADR-0039).

The Jetson half, on the ZED Box Mini, started on 2026-10-17 (docs/32). Erik accepted decisions Q1–Q11 that day: L4T 36.5.2 for both Jetson recipes, Yocto first, the factory image restorable, early-hang protection a gate for 8.4 (#48), and a relay or a person on site for the steps that write the bootloader flash. Phase A, off the box, has started: A1 has read both BSPs and the A/B layout, and Stereolabs’ bootloader changes carry over to 36.5.2 unchanged. The read-only checks that open phase B come next; flashing waits for a USB 2.0 hub, which Erik is getting. #45 is read: Stereolabs’ driver package is fetched at build time, pinned to 1.4.4 for 36.5.2; the carrier’s bootloader and device-tree changes are Fjarr’s own; and Erik asked Stereolabs about the rest on 2026-10-17, including the modules’ source the cameras need on Fjarr’s kernels (#47).

Gate: docs/06 OTA criteria. On every recipe (Yocto and NixOS on the ZED Box Mini; Yocto, NixOS and Ubuntu A/B on the second x86 mini-PC): a power cut mid-write boots the old slot, and a failed health check rolls back and shows in the run log. Once, across the fleet: a staged campaign with an abort, a multi-day budgeted download, and an owner scheduling an update.

Later: depot chunk sharing, the Syslogic AGX Orin board package, statistical release comparison (M7), more peripheral handlers.

Session recording/replay, mobile operator apps, clipboard sync on focus (#41) — open questions.