This is the index of accepted decisions (lightweight ADRs) and the open options awaiting the owner. Agents: read the tables, and open an entry only if your task touches it. When you have a new good idea, add it under "Open" with options and a recommended default, ask the owner, then move it to "Accepted".
| ID | Decision | Alternatives considered |
|---|---|---|
| D-001 | Backend in Rust (stable, edition 2024): tokio, axum, rusqlite (bundled), russh, rustls (ring), tracing | Go, Node, Elixir |
| D-002 | Frontend: SolidJS + Vite + TypeScript + Tailwind v4 + Kobalte. Solid 1.9 now; Solid 2.0 is still a release candidate, so revisit at the M1.8 gate. Superseded by D-045: Solid 2 RC from M1.8. | Svelte 5, Leptos (WASM), React |
| D-003 | Node control: SSH + ephemeral ucode agent (JSON-lines RPC with push events); logs via logd remote syslog; a persistent footprint only in managed mode (transaction guard). Owner's choice. | native Rust agent; ubus JSON-RPC over HTTPS |
| D-004 | Roadmap: observe first, then manage. Owner's choice. | manage first |
| D-005 | Primary host target: home server/NAS/mini-PC container (x86_64/arm64). Pi, armv7 and i686 remain supported. Owner's choice. | Pi-first; running on the router |
| D-006 | License: MIT OR Apache-2.0. Owner's choice. | AGPL-3.0, GPL-3.0 |
| D-007 | Distribution: static musl binaries plus a FROM scratch multi-arch image (amd64, 386, arm/v7, arm64) on GHCR, with everything embedded. Owner's directive. |
distroless, alpine |
| D-008 | Every push to main is a release, versioned with semver derived from Conventional Commits. CI never commits. |
CalVer; an edge channel plus manual stable releases |
| D-009 | Persistence: one SQLite database (WAL) for config, inventory, events, metrics rollups and logs (FTS5) | Postgres, redb, an external TSDB |
| D-010 | Cross builds with cargo-zigbuild; the image is assembled from prebuilt binaries, with no QEMU during builds | cross-rs, buildx emulation |
| D-011 | Config ownership: the controller is the source of truth for each node's managed packages; unmanaged packages are never touched; raw overrides are the escape hatch; brownfield import is lossless | section-level ownership |
| D-012 | Apply protocol: a per-node transaction with a node-side dead-man rollback and a boot guard; confirm only over a fresh connection, after health checks | rpcd uci apply/confirm |
| D-013 | Mesh: batman-adv over 802.11s (SAE), batman over wired uplinks when MTU allows, bridge loop avoidance on | 802.11s only, WDS, EasyMesh |
| D-014 | Roaming: 802.11r/k/v. The controller pushes 11k neighbor reports. 11r defaults to off on WPA3 SSIDs because of a known OpenWrt 25.12 issue. | static neighbor lists |
| D-015 | Firmware: ASU builds with a declarative package set per node; rolling upgrades from leaf to gateway, with health gates | official images plus reinstalling packages |
| D-016 | Supported nodes: OpenWrt 25.12 (primary), 24.10 (best effort), behind an apk/opkg abstraction | — |
| D-017 | API: REST plus one WebSocket (topic subscriptions, deltas); TypeScript types generated from Rust (ts-rs) | OpenAPI codegen, GraphQL |
| D-018 | Web tooling: bun (installs and scripts), Biome (lint and format), Vitest, Playwright | pnpm, ESLint + Prettier |
| D-019 | Task runner: just | make, cargo xtask |
| D-020 | A simulated fleet and --demo mode are first-class, used for development, e2e tests and screenshots |
developing on hardware only |
| D-021 | Controller TLS: self-signed by default, custom certificates supported, ACME DNS-01 later | HTTP only |
| D-022 | ZTP images never contain secrets (ASU is a public service); adopting a device needs the owner's approval | a token in the image for auto-adoption |
| D-023 | Trunk-based development: commit directly to main (owner's directive); main must stay green |
PR flow |
| D-024 | Prompt-cache-aware checkpoints. At a WP end the agent stops for a /clear only when clearing pays off ("Clear or keep" in AGENTS.md); otherwise it continues into the next WP. Basis, checked 2026-10-22: Claude Code on Opus 5.5 with a 1-hour cache TTL; per MTok, input $4, cache write $5 (5 min) or $8 (1 h), cache read $0.20 (0.05×), output $20. Re-derive the thresholds when the model or prices change. Owner's directive; continuing is the owner's choice. |
always clear at a WP end (the old rule); stop at every WP end with a keep/clear line; continue only within a milestone |
| D-025 | Embedded datasets (ISP presets, log patterns, the OUI table) live in the crate that embeds them, under crates/<crate>/data/. The repo-root data/ is the runtime data dir of local runs (UNIWRT_DATA_DIR defaults to ./data) and stays gitignored. Agent's call (reversible), M0.2. |
a top-level data/ for datasets, which collided with the runtime default and the .gitignore |
| D-026 | Local Rust toolchain: rustup, which honors rust-toolchain.toml, so local builds, clippy and tests run the exact toolchain CI pins. Prompted by Arch's rust 1:1.99.1-1 defaulting to a nonexistent linker (x86_64-linux-gnu-gcc). Owner's choice, 2026-10-22. |
linker = "cc" in ~/.cargo/config.toml; a symlink for the missing linker; waiting for a fixed Arch package |
| D-027 | npm supply-chain guard: web dependencies are pinned to exact versions, and bun resolves only versions published at least 7 days ago, transitive ones included (minimumReleaseAge in web/bunfig.toml). Most malicious npm releases are caught within days. Agent's call (reversible), M0.3. The cooldown is superseded by D-049. |
caret ranges; pinning direct dependencies only |
| D-028 | Release build: opt-level = 3 with musl's built-in allocator. Measured on x86_64-musl (M0.4, server CPU time per request): "s" saves 0.5 MB (2.37 vs 2.87 MB) but costs 20–25 % more CPU on one core and 13 % more on four. mimalloc saves 3–5 % CPU at four cores but raises peak RSS from 6.8 to 18.5 MB and idle RSS from 2 to 10 MB. Re-measure the allocator once SQLite and the node sessions exist. Agent's call (reversible), M0.4. |
opt-level = "s"; mimalloc; jemalloc |
| D-029 | Release supply chain: release binaries are built with cargo-auditable, so the image SBOM and cargo audit bin see the exact crates (a stripped static binary is otherwise opaque to scanners). CI pins actions and tools to releases at least 7 days old, and Dependabot proposes weekly grouped minor and patch updates after a 7-day cooldown, mirroring D-027. Agent's call (reversible), M0.5. The cooldown is superseded by D-049. |
an SBOM from the lockfiles as a release asset; Dependabot without a cooldown |
| D-030 | Dependabot updates are batched by the agent: at each WP checkpoint, the agent folds the open, green Dependabot updates into one build(deps) commit on main, so they cost one release instead of one each. Lockfiles are regenerated with the local toolchain rather than taken from Dependabot's branch, and majors go in only after reading their release notes. Dependabot closes the superseded PRs itself. Owner's choice, M0.5. |
auto-merge of minor and patch PRs (a release per bump, unreviewed); the owner merging them; a monthly schedule |
| D-031 | Core types and the store engine. IDs are SQLite integers that the store assigns and never reuses (AUTOINCREMENT). Timestamp is Unix milliseconds (i64: a JSON number and an INTEGER column), shown as RFC 3339 only when displayed. MACs are stored as canonical text. Enum columns hold serde names without CHECK constraints, so a new variant needs no table rebuild. The store uses rusqlite with bundled SQLite and one writer thread that batches operations into a transaction, each in its own savepoint, with synchronous=NORMAL. Crates the agent adds follow the 7-day cooldown of D-029, transitive ones included (just crate-age), so rusqlite is 0.40.2 for now: 0.41.0 was 3 days old. deny.toml also allows Zlib, a permissive license compatible with MIT OR Apache-2.0 (foldhash, through rusqlite's statement cache). Agent's call (reversible), M1.1. The cooldown is superseded by D-049. |
UUIDv7 or ULID IDs; jiff or chrono timestamps as RFC 3339 strings; MACs as integers; one transaction per write; synchronous=FULL; an r2d2 or deadpool pool; sqlx |
| D-032 | SSH transport (M1.2). russh 0.64 with the ring provider and no RSA: OpenWrt 24.10+ dropbear has ed25519 host keys and accepts ed25519 user keys, while the rsa crate carries an unfixed timing advisory (RUSTSEC-2023-0071); SSH certificates are never trusted. Trust on first use is probe, then pin: probe reads the host key without logging in, the user confirms the fingerprint, and every later connection checks the pin (node_host_keys); a mismatch quarantines the link until an admin re-trusts the node. The vault is a stub until M1.6: rows are marked alg = 'plain', and M1.6 reseals them with XChaCha20-Poly1305. Flow control: russh stalls the whole connection while one channel's buffer is full, so each channel is drained by its own task, and a stream with more than 4 MiB of unread output ends with an overflow error. Reconnects use equal-jitter exponential backoff (1 s → 60 s), reset only after a connection stayed up 60 s. The agent restarts on the same connection with its own backoff, and commands keep working meanwhile (degraded mode). The agent envelope (ids, answers, events, ticks, hello, the protocol range) and its multiplexer moved from M1.3 into M1.2, because NodeLink::call needs them; M1.3 adds the ops. deny.toml also allows ISC, a permissive license compatible with MIT OR Apache-2.0 (ring and untrusted). Agent's call (reversible), M1.2. |
aws-lc-rs (banned in deny.toml); RSA support; accepting certificates; trusting the first key without confirmation; known_hosts files; one byte budget per connection; full jitter; reconnecting SSH whenever the agent fails |
| D-033 | Security-fix exception to the crate cooldown: a crate younger than 7 days is allowed when it is the lowest release that fixes a RustSec advisory. It is listed with the advisory ID in scripts/crate-age.sh and removed once it ages out. First use: crypto-bigint 0.7.9 (RUSTSEC-2026-0387; 0.7.0–0.7.8 are yanked) and ctutils 0.4.6 (RUSTSEC-2026-0385, required by crypto-bigint 0.7.9), both through russh. Owner's choice, M1.2. Superseded by D-049: there is no cooldown, so no exception. |
pausing the WP until the fix ages out (2026-11-01); a one-off exception |
| D-034 | Node agent v0 (M1.3). The agent is ucode with two loop modes. With ucode-mod-uloop, a forked uloop.task reads stdin with blocking reads (buffered reads on a non-blocking descriptor lose data) and feeds an event loop, and each exec runs in a task of its own. Without uloop (24.10 images without Wi-Fi), requests are served one at a time and the agent pushes nothing. Collect sets return raw sources (ubus replies, sysfs text, /proc files) and the node crate parses them, so the parsers are tested in Rust against captured fixtures and the agent stays small. Devices come from sysfs, which sees every kernel device, rather than from network.device. The bundle is an uncompressed tar embedded in the binary and installed by hash into /tmp/uniwrt/agent: sha256sum -c under a flock, and the hello must report that hash. Agent's call (reversible), M1.3. |
the agent formatting facts itself; requiring uloop (no 24.10 wired nodes); network.device status; a gzipped bundle (needs a deflate crate) |
| D-035 | Wireless and mesh collection, and the hwsim lab (M1.3b). Wi-Fi state comes from nl80211 through ucode-mod-nl80211, which hostapd depends on, so every Wi-Fi image has it. It is joined with netifd's network.wireless status for the UCI view and hostapd's get_clients for what clients advertised; rpcd-mod-iwinfo is not needed. Radio hardware (GET_WIPHY, about 15 KB per phy even trimmed) is a set of its own, phys (NodeFacts.phys, replacing RadioFacts.capabilities), collected rarely, so radios and stations stay small enough for 2 s ticks. The agent keeps only the fields facts use where a source carries secrets (netifd's reply holds the Wi-Fi keys) or bulk; elsewhere it stays raw (D-034). Neighbors and the bridge FDB come from rtnetlink (ucode-mod-rtnl), or else from /proc/net/arp and brforward. Meshes are the devices of netifd's proto batadv interfaces, and devices with lower devices but no DEVTYPE (batman-adv sets none), confirmed by batctl meshif <dev> mj and read with batctl's JSON queries. 802.11r use is not reported yet: no source says whether a client associated with FT. Wireless fixtures come from the hwsim lab: mac80211_hwsim and batman-adv in the host's kernel, loaded and wired into the containers by a privileged helper container; local only. Owner's choice (hwsim captures; privileged Docker for the root steps); the rest is the agent's call (reversible), M1.3b. |
rpcd-mod-iwinfo (needs LuCI; no DFS state or survey); parsing iw output; capabilities in every radio (the wiphy dump on every tick); spec-only fixtures until M1.7; a CI job for the lab (deferred); the owner running sudo |
| D-036 | Agent streams (M1.3c). The push ops need the event loop; without it they answer unsupported and the engine polls. Topics (subscribe/unsubscribe): hostapd (each BSS's object), netifd (network.interface and network.device, not the network.interface.<name> objects, which repeat the notifications) and dhcp (dnsmasq, odhcpd). An event is <source>.<type> with the daemon's data unchanged (D-034); node::events parses it into model::event and model::log. A ubus notification does not say which object sent it, so each object gets a subscriber of its own, and objects that appear (ubus.object.add) are subscribed to, so hostapd's survive wifi reload. The agent drops hostapd's probe requests. Ticks (set_interval): one timer per interval carries all of its sets, a set has one interval at a time, and intervals are at least 1 s. Sets are collected in the loop, as collect is, because forked tasks would share the ubus and netlink sockets. Logs (log_follow): by default logd is polled by id every second, which keeps sequence numbers and ms times and counts gaps as dropped. stream runs logread -f -t -l <N+1> in a task, which backfills and follows in one request to logd; the answer waits until logread follows, and logread is killed on stop and when the agent exits. That line count is the one value formatted into a shell string (%d of a checked integer), because popen takes no argv. hostapd's sta-authorized names the authentication algorithm, ft for an 802.11r fast transition: the source D-035 lacked. Owner's choice (both log modes, polling by default); the rest is the agent's call (reversible), M1.3c. |
logread -f only; polling only; one subscriber per topic (cannot tell the objects apart); the per-interface netifd objects; ticks collected in forked tasks; backfilling a stream over ubus first (loses the lines logged before logread connects) |
| D-037 | Engine (M1.4). One actor per node (a tokio task, restarted with backoff if it panics) keeps the node's live facts and status, and sends its agent one request at a time, so an agent that serves requests one at a time is never asked twice at once. After each hello it subscribes to the topics, collects every set, then schedules ticks; an agent without an event loop, or one that refuses set_interval, is polled with collect on the same plan. Intervals are data (Intervals): 15 s for system, devices, interfaces, radios and stations; 30 s for neighbors and batman; 60 s for leases (DHCP events refresh them sooner); 1 h for capabilities and phys; board only when the agent starts. While the UI has a node open (Engine::focus, a guard), system, devices, radios and stations go to 2 s and batman to 10 s. A pushed event collects the set it touches again 500 ms later, and the events in that time share one collect. Status: offline while the link is not up (connecting, unreachable, quarantined); degraded while the agent is starting, failed or absent, a request or a set fails, or no telemetry arrived for three of the fastest intervals (at least 30 s), which also asks the agent for its pushes again, since it may have restarted unseen; else online. Facts are replaced, never mutated (readers hold Arcs), and Event::Facts goes out only when a set's part changed. Roams: a client authorized at another access point while it is still connected at the first, or within 15 s of leaving it; station lists seed the clients that joined before the engine watched. Logs are followed only while someone holds Engine::follow_logs (M2.5 decides whether storage holds one for every node); a follow that starts again after the agent restarts backfills 200 entries and skips those already announced. Snapshots: each node's latest facts, saved every 5 min when they changed and at shutdown. Agent's call (reversible), M1.4. |
concurrent requests per node; one interval for every set; polling only; collecting only on the timer after events; a snapshot on every change (a write per node per tick); following every node's log all the time; roams from station lists only |
| D-038 | Simulator and demo mode (M1.5). Link type: the server runs the engine on FleetLink, an enum of SshLink and SimLink, so the M1.6 API has one engine type (owner's choice). Demo store: serve --demo opens a fresh database in a demo-* temporary directory inside the data directory (a scratch image has no /tmp) and deletes it at exit, so every run starts the same from its seed (--demo-seed; owner's choice); a run that crashes leaves its directory behind. Agent's calls (reversible): the crate uniwrt-sim renders every collect set and push in the agent's raw shapes, so a simulated node goes through the real parsers (its phys are the hwsim capture's wiphys cut to one band each, crates/sim/data/phys.json). The world: a GL-MT6000 gateway and GL-MT3000 access points in a 28 × 16 m house; 30 clients of 10 kinds dealt from a fixed deck; indoor path loss (30 log d + 0.5 dB/m) with slow fading; 5 GHz preferred from −72 dBm; roaming below −62 dBm to a candidate 8 dB better, with 802.11r for phones and tablets (the old AP sees the client leave 1–3 s later); traffic capped by the link's rate; leases renewed at half time. One AP is wired to the gateway and the others reach it over batman-adv on 5 GHz, multi-hop where that is better. An AP reboots on average every 90 min: it is down for 80 s, its link is retried with backoff, and a new agent starts. Addresses come from documentation ranges (203.0.113.0/24, 2001:db8::/32). Real OUIs appear only for kinds of device that don't randomize their MAC. The world's dynamics depend only on the seed and the number of steps, and its timestamps count from the clock at spawn. A 15-minute warm-up makes it start settled, with logs. exec, stream and upload answer unsupported. |
a generic API over the link; a dyn-compatible link trait; <data-dir>/demo.db kept across runs; no store in demo mode; facts made directly (skipping the parsers); replaying the hwsim captures |
| D-039 | Vault and TLS (M1.6). Split: M1.6 became vault and TLS, M1.6b the api crate with auth and sessions, M1.6c REST and the WS hub (owner's choice). Trust: the controller is a local CA (ECDSA P-256, 10 years, pathlen:0) with critical name constraints (localhost, lan, home.arpa, local, internal, loopback, private, shared and link-local ranges, plus the names the first leaf needed beyond them), and signs a leaf for the HTTPS listener; devices trust the CA once, and renewals or new names never need re-trusting (owner's choice). Plain HTTP redirects to HTTPS, except the health check and a reverse proxy that terminates TLS, which gets the whole application (owner's choice; made --trusted-proxies <networks> rather than a boolean, so only the proxy's own requests skip the redirect). Agent's calls (reversible): vault values are XChaCha20-Poly1305 under an HKDF-SHA256 subkey of the master key, with the secret's name as associated data; opening tries the key on every sealed row (a wrong key fails at startup) and reseals the stub's rows in the same transaction; a missing key file is replaced only while nothing is sealed; UNIWRT_MASTER_KEY_FILE for Docker secrets; demo mode's key lives in memory. The CA and leaf are JSON in the vault (tls/ca, tls/leaf) with their names and expiry, so nothing parses X.509; the CA's key is read only to sign. The leaf lasts 397 days (Apple's limit is 398), is checked daily, reissued 30 days before expiry or when its names or CA change, and hot-swapped through a rustls cert resolver. Its names: localhost, uniwrt.lan, the host name and <host>.lan (not Docker's 12-digit IDs), loopback, the listen address, the private addresses the host routes from (found by connecting a UDP socket), --tls-names. Redirects are 307 (not cached) to the HTTPS listener's port; no HSTS while the CA is local; the CA downloads only over HTTPS (/api/v1/tls/ca.crt) with its fingerprint at /api/v1/tls and in the log. HTTP/2 over ALPN; at most 128 handshakes at once, 10 s each; ConnectInfo<Peer> on both listeners for M1.6b. |
a self-signed leaf; redirect only; a --behind-proxy flag; certificate files in /data/tls; x509-parser; the master key used directly; ChaCha20-Poly1305 with 96-bit random nonces; a fresh leaf each start (Firefox exceptions are per certificate); 308 redirects; HSTS; the CA over plain HTTP; axum-server |
| D-040 | API crate and authentication (M1.6b). Setup is accepted only within 10 minutes of a start without users; a restart reopens it (owner's choice). Agent's calls (reversible): the api crate owns the routes, problem+json, the security headers and auth; the server keeps the UI, TLS and the listeners, and tells the API who the client is through a Client request extension (over HTTPS the peer; through a trusted proxy the rightmost X-Forwarded-For hop that is not a trusted proxy, X-Forwarded-Proto/-Host), because axum's ConnectInfo cannot be implemented for another crate's listener. TLS details reach the API through a Certificate trait. Passwords: 12 characters minimum, 1,024 bytes maximum, no composition rules; Argon2id at OWASP's minimum (19 MiB, t=2, p=1), two hashes at a time on the blocking pool, a dummy hash computed at startup for unknown users. Throttling in memory: per client (IPv6 /64) 10 failures per 15 minutes; per username 5 free failures, then 1 minute doubling to 15. Sessions: 256-bit tokens in __Host-uniwrt-session (HttpOnly, Secure, SameSite=Strict), stored as SHA-256, 7 days idle and 30 in all, use recorded every 5 minutes at most, ended ones deleted at login. CSRF: X-Uniwrt-Csrf: 1 on every state-changing request plus our Origin when sent, and Sec-Fetch-Site: cross-site refused. Audit: setup, login, failed login, logout. Demo mode creates demo / demo. API tokens wait for a WP that needs them. |
a setup code in the log (recommended, not chosen); first come, first served; account lockout without expiry; persistent throttling; sessions in memory; storing raw tokens; double-submit CSRF cookies; a CSRF check only on some routes |
| D-041 | REST and the WS hub (M1.6c). The API reaches the engine through uniwrt_engine::Fleet, a link-free, dyn-compatible face (all, node, focus, follow_logs, subscribe) that every Engine<L> implements; ApiConfig takes an Arc<dyn Fleet> and the server keeps Engine<FleetLink> (owner's choice). Agent's calls (reversible): REST: GET v1/nodes (NodeSummarys: the record, the status, and the headline numbers of the facts; an adopted node the engine doesn't watch shows as offline and stopped) and GET v1/nodes/{id} (NodeDetail: record, status, facts). WS: each {"sub":[…]} replaces the connection's topics (fleet, node:<id>, logs?node=<id>&min=<severity>); a new topic answers with a snapshot; deltas are merged per topic and flushed every 100 ms (at most 10/s): a fleet delta carries only summaries that changed, and a node delta the latest status, a FactsPatch of the changed parts and the events and roams in order (256 at most per flush; logs 1,000, with the rest counted as dropped). Messages are a union tagged by type that names the topic as the client spelled it. A connection that lags the bus gets every snapshot again. A held node: topic holds focus, and a logs topic holds follow_logs. The logs snapshot stays empty until M2.5 stores logs. Security: a session is needed, and Origin, when sent, must be ours; the session is checked again every minute (and touched, so an open UI counts as use), and when it ends the connection closes with 4401. Pings go out every 30 s, a peer silent for 75 s or a send stalled for 10 s is dropped, and client messages are at most 16 KiB. Stopping the server closes WebSockets with 1001. HTTP/2: the server advertises extended CONNECT (RFC 8441), so browsers open the hub with CONNECT: the route takes GET and CONNECT, and the CSRF layer lets CONNECT through like GET. Types: core and model derive TS behind a ts feature that only api enables; ts-rs's serde warnings are off, so a type with custom serde sets ts(type = …) or ts(as = …). The fleet topic reads a node's record when it first sees the node, so later changes to a record (renames, M1.7) need an event. |
a generic Api<L>; FleetLink below api; incremental subscribe/unsubscribe messages; a delta per event; whole facts in every delta; DTO mirrors of the model types; GET-only upgrades |
| D-042 | Jobs and adoption (M1.7). Split: M1.7 is the jobs crate, the adopt job and its API; M1.7b discovery and batch adoption from the gateway's DHCP leases; the wizard becomes M1.8b, after the app shell (owner's choice). Jobs are history, not resumable (owner's choice): a job runs in memory and is saved at each step (jobs table); one still running at a start was interrupted and is marked failed (Interrupted), as are the running ones at shutdown, so a step never runs twice behind the user's back. One job at a time per target. Backups are sealed in the database (owner's choice): sysupgrade -b - tarballs hold password hashes, private host keys and Wi-Fi keys, so each is sealed like a vault secret, with backup/<sha256> as associated data (node_backups), not written to /data/backups/. Agent's calls (reversible): every check runs before any change: OpenWrt 24.10 or later (or a snapshot), 8 MiB available memory, 1 MiB free in /tmp (a warning under 64 KiB free on /), an identity, and not adopted yet; the node must present the host key the user confirmed. Identity: the label MAC, else the MAC behind lan, else the lowest globally administered Ethernet MAC. Role (unless the user gives one): a gateway when an interface named wan*, or with a PPP or cellular protocol, is up with a default route; otherwise an access point. Packages come from opkg's status file, or apk's installed database plus its world file (the wanted ones), read through grep without running the package manager. sysupgrade -b - writes the tarball to stdout and changes nothing (verified in the containers, which also check that only authorized_keys changes). The key is appended to /etc/dropbear/authorized_keys unless present. The root password is kept (in the vault, node/<id>/root_password) only if the user asks. Node, pin, facts, inventory and backup are recorded in one transaction; the adoption is audited with the target as detail. Failures are a JobErrorCode the UI can act on (unreachable, host_key_mismatch, auth_rejected, unsupported, already_adopted, node_failed, interrupted, internal). Demo mode has no SSH dial, so adopting answers 409. |
Resumable jobs (a step could run twice, unseen); backups as files under /data/backups/ (unsealed secrets on disk); the wizard before the app shell; changing anything before every check passed |
| D-043 | Every test suite in CI (owner's request). The hwsim lab runs as the CI job wifi on every push, ending D-035's deferral: GitHub's runners load mac80211_hwsim and batman-adv (their Azure kernel ships both; linux-modules-extra-$(uname -r) is installed if not), and the lab's privileged helper container runs there as locally. The runner needs iw and wireless-regdb: without the regulatory database the radios keep the world domain, where channel 36 is no-initiate-radiation and the 5 GHz APs and the mesh never start. Its readiness now also waits for the gateway's 802.11s peer link and both batman neighbors, since the client's addresses can arrive over the wired backhaul alone (that race failed a local run). Cache traffic (owner's choice, after a restore was refused for "egress over the account limit" and the builds took 13 minutes): Rust caches are saved from main only; agent, transport and wifi share one cache that agent saves; transport and agent keep both releases on one runner, since a per-release split gained no wall-clock time and cost two more restores per run. Agent's calls (reversible): the image waits for every test job, web and wifi included, so a release never skips a suite; ui builds the bundle apart from web's checks, so the builds start about 10 s sooner. The release profile (fat LTO, one codegen unit, D-028), SBOM, provenance and signing stay: they are most of the critical path, and they are quality. |
Speeding up with thin LTO or fewer codegen constraints (slower or larger binaries); skipping arm builds on branches (less coverage); retrying failed tests (hides flakiness); a runner per OpenWrt release (more cache downloads, no gain); nextest archives for the container jobs (wifi would start later and lead the critical path) |
| D-044 | Discovery and batch adoption (M1.7b). Probe scope (owner's choice): every DHCP lease and every IPv4 neighbor on an adopted node's non-WAN interfaces is probed on port 22, not only leases, so that APs with static addresses and custom hostnames are found. A probe reads the SSH banner over plain TCP and, for dropbear or a host named OpenWrt/uniwrt-*, the host key; it never logs in. Agent's calls (reversible): discovery is computed on request (GET v1/discovered) from the engine's latest facts, waiting up to 3 s for new probes and saying scanning; it has no WS topic yet. Probes run 4 at a time, and a result holds 10 minutes per (MAC, IP). Batman originators are mapped to addresses through the wired clients they announce. An originator that announces an adopted node's MAC is that node. An originator with no reported device is reported with no address. Reported: dropbear servers, those hostnames, and such originators; other SSH servers (OpenSSH) only under those names. POST v1/adopt/batch checks the whole batch first (1–64 nodes, no duplicates, Node n: in the error), then starts an adopt job per node with the shared password, refusing only those a job runs for already. The audit has an entry per job. At most 4 adoptions run at once, since each holds a login and a backup of up to 32 MiB in memory; the others wait with their steps pending. |
Probing leases only (misses static APs); no probes (misses renamed wired APs, and keys only on demand); a parent batch job (one failure would hide the others' state); unbounded parallel adoptions (memory on small hosts) |
| D-045 | Solid 2 release candidate (M1.8 gate; supersedes D-002's "Solid 1.9 now"). Owner's choice, after a spike: migrate now rather than after the UI grows. The set is the newest that installs together inside the 7-day cooldown (D-027): solid-js, @solidjs/web and @solidjs/signals 2.0.0-rc.16 (signals pinned by an override), @solidjs/router 2.0.0-next.41 (routes as config objects, plain <a> links styled through aria-current and data-active), @tanstack/solid-query 6.0.0-rc.5, @kobalte/core 2.0.0-alpha.4 (its peer is exactly rc.16), vite-plugin-solid 3.0.0-next.27 and @solidjs/testing-library 1.0.0-rc.2. Overrides pin @solidjs/signals, @solidjs/compiler and @solidjs/babel-plugin to rc.16: a newer compiler emits helpers the rc.16 runtime lacks. Kobalte is patched (web/patches/, applied by bun install): its dismissable layer registered in onSettled, which ran before the ref was set, so Escape and outside clicks never closed a dialog, popover or menu; the patch registers in an effect on the ref. Drop it once a fixed Kobalte release ages in; report it upstream. Timing: in rc.16 onSettled can run before refs are set, so code that needs an element runs in an effect on a ref signal. Replacements: lucide-solid and @tanstack/solid-virtual have no Solid 2 builds, so ui/Icon renders the framework-free lucide icon data, and the table virtualizer will wrap @tanstack/virtual-core. Query data: in solid-query 6 data suspends until the first answer, but a failed first fetch resolves it to undefined instead of throwing, so components branch on isError/isSuccess and read data under <Loading>. Raising the RC versions is a manual step: the Kobalte patch is tied to its version, and the peers must match. |
stay on Solid 1.9 until Kobalte 2 is stable; Solid 2 without Kobalte (our own accessible primitives) |
| D-046 | UI direction: three skins (O-5). Owner's choice after rendered mockups: the look is selectable rather than fixed. Skins: Graphite pro (the default: neutral, crisp, dense, hairline borders), Midnight glass (translucent layers over a glow, neon accents, rounded) and UniFi-familiar (white cards on soft shadows, an icon rail, a blue accent). Each has a light and a dark theme, so skin and theme are independent choices, both saved per browser and applied before the first paint. Tokens: tokens.css keeps one token set; <html data-skin> re-binds surfaces, borders, text, accent, radii, shadows, blur and density per skin. Status, band and link-quality colors are shared by all skins (one meaning everywhere). Midnight glass drops its blur and glow under prefers-reduced-transparency and on narrow screens. UniFi-familiar turns the sidebar into an icon rail with tooltips (the drawer on phones keeps labels); skins also set row height. The skin details are the agent's call, M1.8. |
one fixed direction (Graphite pro was tentative); per-user skins stored on the server |
| D-047 | Sign-in and the live client (M1.8c). Agent's calls (reversible): Gate: the app asks GET v1/auth and shows setup (open), a "setup closed" note, or login until a user is signed in; only then does it build the router, the shell and one LiveSocket. A 401 from any route but sign-in, or the hub closing with 4401, drops the cached data and returns to login with a notice. Sign-out lives in an account menu in the sidebar footer and in ⌘K. Socket: one WebSocket per session; components subscribe to topics and the socket sends the whole set (a microtask after a change); a second subscriber to a held topic asks for a fresh snapshot (the set without it, then with it), as does a node delta that patches facts before any arrived; messages are queued and delivered once per animation frame, or after a second when no frame comes (hidden tabs); reconnects back off 1 s doubling to 30 s with jitter. Stores: the fleet is one store, sorted gateway first, merged in place so rows keep their identity (in Solid rc.16 reconcile with a key replaces array items); a node view keeps the detail, the last 100 events and 60 CPU and memory samples. Clients of access points are named from the gateway's leases, fetched by GET v1/nodes/{gateway} once a minute so the gateway's focus is not held. E2E: Playwright 1.64.1 in the installed Chrome (no browser download), against the release binary (CI job e2e after build; the image waits for it) or a debug build (just e2e), serving --demo --demo-seed 42. |
a module-level socket; a socket per page; applying each message on arrival; snapshot-only node topics; reconcile by key; hostnames only from the node's own leases; Playwright's bundled Chromium; E2E only on main and nightly |
| D-048 | The adoption wizard and the demo's adoption (M1.8b). Demo (owner's choice): serve --demo leaves the last access point unadopted; it keeps running in the world, the gateway's leases and mesh show it under Discovered, and the real adopt job and discovery run against it through DemoDial (server), which plays dropbear (banner, a host key made at start, root password demo, the controller's key once installed) and paces its answers like a LAN (a whole adoption takes about 5 s); other addresses refuse at once, as LAN clients do. The simulated node runs the commands adoption runs (sim/src/render/commands.rs: the release, the authorized_keys script, opkg's or apk's database, a small embedded backup) and keeps authorized_keys across reboots. UI (owner's choice): a dialog over the Devices list, not a page or a drawer. Agent's calls (reversible): the wizard's URL is /devices?adopt=1&host=… (⌘K "Adopt a device" opens it; closing replaces the URL, and only while open, since Kobalte also reports a close when the page unmounts); steps: address and password (host:port accepted; an empty password is left out, so the controller's key is used; "remember" needs a password) → probe → fingerprint with the dropbearkey command to compare, optional name and role (Detect, Gateway, Access point) → the job's steps live from the jobs topic (the POST's answer shown until the topic reports it) → "Open the device". Failures are explained by code, with an action each: auth_rejected back to the password (cleared), unreachable/unsupported back to the address, host_key_mismatch reads the key again, already_adopted only closes, the rest retry. The Discovered card sits under the list, so its appearing never moves rows under the pointer; it is hidden while empty; it is polled into a signal every 15 s (2 s while scanning), skipped while the tab is hidden, because pending query data would hold every navigation away from the page (router 2); "Adopt n selected" shows once something is selected. Batch: every fingerprint in full, one password, then a progress bar per job with its failure explained, and the refused devices with their reasons. A native checkbox primitive (ui/Checkbox). Dialog keeps the focus the user placed inside it before the focus trap's deferred auto-focus (one paint later) runs, which otherwise moved typing into the first field. |
Pre-adopting every simulated node (nothing to adopt); a fifth, spare access point (changes the world); a full page or a drawer for the wizard; polling discovery with solid-query (blocked navigation); the Discovered card above the list (rows moved under the pointer) |
| D-049 | Newest versions, no cooldown (supersedes the cooldowns of D-027, D-029 and D-031, and D-033). Owner's choice, 2026-10-30: crates, npm packages, the Rust toolchain, the cargo tools, bun, zig, CI actions and runner images, and the test images are taken at their newest releases, majors included, as soon as they are out. minimumReleaseAge left web/bunfig.toml, Dependabot has no cooldown, and just crate-age with scripts/crate-age.sh is gone. Supply-chain checks that remain: exact pins and lockfiles, cargo-deny's advisory check, cargo-auditable in the release binaries, and Dependabot's security updates. A version is held back only when another dependency requires it, such as tokio-tungstenite 0.29 while axum 0.8.9 pins it, or zig 0.16.0 in the cargo-zigbuild image, which scripts/cross.sh replaces with zig 0.17.0. First sweep: rusqlite 0.41, russh 0.65 (its server's auth_publickey takes a VerifiedPublicKey), brotli 9.1, Solid 2.0.0-rc.20 with router 2.0.0-next.43, cargo-nextest 0.9.150, cargo-zigbuild 0.24.0, zig 0.17.0, install-action 2.88.1, and CI on ubuntu-26.04. |
the 7-day cooldown (D-029); a 1–2-day cooldown |
| D-050 | Dev accelerators (M1.9). Owner's choices: just new-feature <area> scaffolds the feature recipe wired in and compiling (a model type, a route with a DTO deriving TS and its test, the web page with its test, lazy route, client and strings; --telemetry adds the collector, parser and fixture), and just arch-check enforces the web import directions as well as the crate rules. Agent's calls (reversible): the crate rules are read from ARCHITECTURE.md's table, so the doc is the rule and a crate missing from it fails; dev-dependencies may also use the test crates. L0 purity is checked in the dependency tree (no async runtime, socket, SQLite, SSH or HTTP crate) and in the source (std's fs, process, env, thread and os, sockets, terminal IO, clock reads), with // arch-check: allow (<reason>) on the system clock's line. Both tools are bash with jq over cargo metadata and cargo tree; scripts/arch-check.test.sh runs the check on fixture trees. The scaffold writes nothing on any conflict, makes a page only for one listed in app/nav.ts, and prints, rather than guesses, the matching Rust and ucode registrations that turn a collector into a collect set. Its end-to-end test (just test-new-feature, in a throwaway worktree) runs in CI's rust job, not in just check, where it would add a minute. Found along the way: Dialog and Drawer returned focus to the body on close, since Kobalte returns it to a Trigger they don't use; they now remember the element that opened them, and the test waits for focus to enter the dialog before pressing Escape. |
a Rust xtask; a separate rules file; the scaffold test in just check |
| D-051 | Topology (M2.1): built in the engine, ids by node and MAC, diffs to the UI. The pure topo crate builds the graph from every node's facts plus what facts don't say: each node's role and adoption MAC, which Engine::add now takes (Identity). The engine rebuilds it 1 s after any facts or status change (one build per second at most, whatever the fleet's size), keeps the latest for GET v1/topology, and announces it on the bus; the WS topic sends the snapshot, then the diff against what each client has, by stable ids (node:<id>, client:<mac>, switch:<node id>:<port>, peer:<mac>; edges by their ends, medium and band). Wired links come from the bridges' FDBs by segment (whatever another node there has on a port other than the one toward us is behind it; segments that name each other are one; three or more ends make an unmanaged switch); mesh links merge 802.11s and batman reports per pair and band; the tree follows the cheapest paths from the internet, batman's best path toward the gateway cheapest. Each edge's traffic comes from one end's counters (the gateway's first), its rates from the previous build. Found on the hwsim captures: Docker's bridge joining the lab's containers is, correctly, an inferred switch. Implementation detail, decided and noted. |
building per WS connection in the API; names in the graph (the UI joins them from fleet); a full graph per change instead of diffs |
| D-052 | Topology view (M2.2). Owner's choices: no split (canvas, layout, views, details and replay in one WP), and pinned positions live on the controller (topology_layout, GET/PUT/DELETE v1/topology/layout[/{vertex}]): one layout for every user and browser, shared by the RF and clients views, at most 4,096 pins. Agent's calls (reversible): History: each build as Topology::for_history keeps it (no traffic, CPU or memory; signals rounded to 2 dB and quality to 5 points), a keyframe every hour and deltas between at most every 30 s, pruned at each keyframe to what the last 24 h need (TopologyHistory in the engine, topology_history in the store). The store keeps frames as opaque JSON (it may not depend on topo); the engine encodes them and uniwrt_engine::replay decodes them for GET v1/topology/at/{ms}; GET v1/topology/timeline gives the start and the changes. The replay shows shape, states and quality, not traffic. Views: tree (uplinks only, tidy tree, clients grouped by default), RF / mesh (no internet, no clients, every link between the rest), clients (all, ungrouped by default); a host's clients group or ungroup per view. Rendering: Canvas 2D drawing only while something moves; d3-force (seeded, so a scene lays out the same way) in a Web Worker, re-run only when the scene's shape changes; Lucide icons as Path2D; colors resolved from the tokens and repainted on skin, theme or system changes. Pins load when the page opens (no live sync yet). The timeline is polled into a signal (D-048). |
|
| D-053 | Clients (M2.3). Owner's choices: kick and block wait for M3 (they change nodes, which needs management mode and transactions; the client page shows them disabled, and they became M3.8, since folded into M5.1 by D-054); the whole IEEE registry (MA-L, MA-M and MA-S, about 54k assignments, vendor names normalized: corporate suffixes dropped, shouting tamed, well-known brands named as people know them) is embedded zlib-compressed in crates/clients/data/oui.bin (about 450 KB, unpacked on first lookup to about 1 MB) and rebuilt by just oui (scripts/oui.py), never by the build; a Signal tab now, from an hour of samples (every 5 s at most) kept in the engine's memory until M2.4 stores metrics. Agent's calls (reversible): a pure L0 crate clients (core, model, topo) holds the book: clients are the topology's client:<mac> vertices, online while their link is up and their node not offline; a session per place (an AP's interface or a node's port), a roam when the place changes while connected or within the 15 s roam window after leaving, and an association that began before its session ended (from the station's connected time) continues that session, so restarts and nodes going quiet don't split them. Records and sessions persist in clients and client_sessions (migration 0006) through ClientRecorder, which restores the book before the nodes are added, saves last_seen at most every 5 min while online, and prunes hourly: sessions older than 30 days, clients without an alias unseen for 30 days. Randomized addresses group by a non-generic hostname, else by a DHCP client ID that isn't the MAC, and share an alias. Device kinds come from hostname then vendor rules (rules.rs); DHCP vendor classes and mDNS wait for collectors that gather them. Experience score v1: SNR (else signal) 35, PHY rate against the client's and radio's best 25 (only while it moves traffic), retries per frame (failures fivefold) 20, channel busy 10, roams in the last hour 10; missing parts drop out, and sticky-client detection waits for M2.6's RSSI matrix. API: GET v1/clients, GET/PATCH v1/clients/{mac} (renames by admins, audited), GET v1/clients/{mac}/sessions?since=, WS topics clients and client:<mac> (holding it focuses the client's node and follows it as it roams). |
kick now on managed nodes only; kick in observe mode; a curated OUI subset (~40 KB); MA-L only; Overview and Timeline only |
| D-054 | WPs that share context are merged (2026-10-31). Owner's choices: M4.1 + M4.5 → M4.1 WAN, SQM & failover (one WAN render; failover health checks reuse the connectivity test); M4.2 + M4.3 → M4.2 Networks & firewall (a zone per network, the matrix seeded by each network's purpose); M4.4 becomes M4.3. M3.8 (client kick and block) is folded into M5.1 WLANs & client actions (the same wireless render and hostapd ubus code), so the client page's buttons stay disabled until M5. M5.2 + M5.5 → M5.2 Roaming & steering (hostapd rrm_nr_set/BTM, client 11k/v/r capabilities, wpad and usteer, the roam history). M7.3 + M7.4 → M7.3 Backups, restore & replace (replace and re-adopt are a mapped restore); the controller update notice moves to M7.1. M8.1 + M8.3 → M8.1 Network services & packages (one spec section, one install-then-render pattern); M8.4 and M8.5 become M8.3 and M8.4. Pairs that would add up to more than L stay separate (M2.5/M2.6, M3.3/M3.7, M3.4/M3.6, M6.1/M6.2, M7.1/M7.2, M8.4/M8.5): run them back to back and keep context instead. Superseded in part by D-055, which merges those pairs too. |
keep every WP; merge only M5.2 + M5.5; keep M3.8 in M3; defer the M8 merge to M8's refinement |
| D-055 | The remaining WP pairs that share context are merged too, each with split points (2026-10-31). Owner's choice, after the agent showed that merging alone doesn't lower cost: every request re-reads the whole context, so a finished part's leftovers (often 60k or more) cost more over the next part's requests than a 2× reload of the shared ~25k (AGENTS.md "Clear or keep"). So merged WPs keep one row and one acceptance list, but each names its parts (`Split: a | b), and every split point is a checkpoint with the clear-or-keep test, as at the end of a WP. New WPs: **M2.5 Logs, events & alerts** (M2.5 + M2.6; RF insight becomes M2.6); **M3.3 render` v1 & system test harness** (M3.3 + M3.7); M3.4 Transactions, jobs & review UI (M3.4 + M3.6), before M3.5 Brownfield import & drift, so transactions still come before import and the review UI gains raw overrides and drift in M3.5; M6.1 RF planner & apply (M6.1 + M6.2; floor plans become M6.2); M7.1 Firmware & rolling upgrades (M7.1 + M7.2; backups and replace become M7.2); M8.3 Diagnostics & troubleshooting (D-054's M8.3 + M8.4). D-054's merged WPs got split points too. Size XL means two L-sized parts. |
| D-056 | Metrics (M2.4). Owner's choices, 2026-10-31: the pure logic lives in a new L0 crate metrics (sampling facts, topology builds and client samples into series; retention and the resolution a query reads; LTTB), with the series' types in model::metric so store needs no new dependency; raw values are one per series per aligned 15 s window (the average of what arrived; about 30 MB for 4 nodes and 30 clients over 48 h), not every 2 s sample; the dashboard's topology mini-map reuses the topology canvas renderer, which moves to web/src/viz/topology; and M2.4 also builds the Metrics explorer (/insights/metrics). The WP splits into a storage & API |
b charts and explorer |
| D-057 | Charts and the Metrics explorer (M2.4b). Agent's calls (reversible): uPlot 1.6.32, imported on first use from a shared chart chunk (≈ 22 KB gzip, outside the initial bundle), colors read from the tokens through a probe and redrawn on skin/theme changes; without a canvas (jsdom) only the HTML legend renders, so tests read legends and role="img" names. Charts live in web/src/viz/timeseries, a new web layer that may use ui/, lib/ and api/ (features and the gallery may use it; arch-check enforces it). Stored series are polled into plain signals, never queries (D-048), every 15 s for an hour up to every 15 min beyond a week, and not while the page is hidden. Series from separate LTTB reductions are merged onto one time axis: undefined where a line has no point of its own (spanned), null where its data stops (a break). Ranges are presets (1 h … 1 y) or a dragged window; the device page starts at 24 h, the client tabs at the live hour (from memory) with 24 h, 7 d and 30 d stored. The device page shows one interface's throughput at a time (WAN device, else LAN, else the first; a native select for the rest). The explorer keeps entity, metrics, range and zoom in the URL, groups metrics into one chart per unit, keeps at least one metric chosen, and names nodes from the fleet and clients from one GET v1/clients. Traffic totals integrate the chart's points (an estimate, labelled so). A new ui/Segmented toggle group replaces the timeline's hand-rolled one. |
|
| D-058 | Dashboard and the topology mini-map (M2.4c). Agent's calls (reversible): the topology's graph, scenes, layouts, canvas renderer and TopologyCanvas live in web/src/viz/topology (the topology page keeps its details panel, replay, legend and view switch); the palette reads the tokens through viz/theme.ts. The renderer takes options: fixed (the camera stays fitted; hover and click only, so a map in a scrolling page leaves the wheel and touch to the page; no focus) and insets (a fixed map keeps 20–36 px clear, not the page's room for overlays). The layout worker starts with the first force view, so tree views (and the mini-map) never start one. Below 1× zoom, vertex labels shrink with the map (to 0.75×, 9 px), so neighbours' labels stop colliding. The clients list store (createClientsView, averageScore, scoresByNode) moved to lib/clientList.ts for the dashboard. The dashboard reads the fleet, the live topology (WAN state and rates, mesh links) and the clients topic; the gateway's WAN addresses come from GET v1/nodes/{id} every 30 s while visible, not from node:<id>, which would make the engine collect the gateway more often for as long as the landing page is open (D-041). Health: the internet is online while a WAN link has a default route, unknown without a gateway or while it is offline; the mesh counts links between adopted nodes only (a link down is critical, else the weakest quality: good from 60, fair from 40); Wi-Fi is the mean experience of online Wi-Fi clients; nodes are critical with one offline, fair with one degraded. Top clients: the five online clients moving the most now (by their AP's counters), only those moving any. With no nodes adopted, the dashboard is a welcome with a link to the adoption wizard. |
|
| D-059 | Logs backend (M2.5a1, 2026-11-01). Owner's choices: the agent follows every node's log at all times, and syslog takes over while it arrives: the agent stops following a node once its syslog arrives, and follows again (its backfill skipped through what is stored) when the TCP connection closes or UDP is quiet for 10 min; around a switch, an entry one source delivered is skipped when the other delivers it within 30 s. Syslog from other hosts is stored under the sender's host name (or address). M2.5a is split into a1 (backend) and a2 (UI). Agent's calls (reversible): the syslog crate (L1, now also on model) holds the parser, TCP framing, the receiver (200 msg/s per sender with bursts of 2,000, 64 TCP connections, a 4,096-message queue; drops counted) and the annotations (data/log-patterns.toml, regex + toml, the first match wins, its id stored with the entry); logs with a contentless FTS5 index (contentless_delete), ids in arrival order, node not a foreign key; retention by arrival time (14 d) and an estimated size (1 GiB), at most 10,000 entries per prune; RFC 3164 times (no zone) are replaced by the arrival time unless logd's [s.ms] is there; loopback addresses and addresses or names several nodes share name no node; text search is words, phrases, prefixes and exclusions, each quoted for FTS5; saved searches are shared by all users, by unique name; the WS logs topic reads storage (200 newest, then Event::Logged), may name a node or a host or neither, and no longer holds a follow interest (Fleet::follow_logs removed); the receiver listens on 0.0.0.0:5514 unless --syslog-listen off, and is off in demo mode unless given; the simulator logs hostapd's inactivity deauths and roams' disassociations. |
the agent only while the UI follows (no history); syslog only (needs a change on every node); dropping syslog from adopted nodes; storing other hosts' syslog not at all; a content FTS table (text stored twice); retention by the entry's own time (a node with a wrong clock would never be pruned) |
| D-060 | Logs UI (M2.5a2, 2026-11-02). Owner's choices: newest entries on top (older pages load below; following pins the top, scrolling away holds arrivals behind "Show N new"); a row expands in place, and its context opens in a wide dialog (± 20 entries from every source, up to 200). Agent's calls (reversible): nodes and hosts in a filter are one set of sources (an entry from any one passes; before, both together matched nothing), in the store, LogFilter::admits and the page; the first page and older ones from GET v1/logs, arrivals from the WS logs topic narrowed where the hub can, the rest of the filter (full text included) applied on the page as the store does; pause and scrolling away both hold arrivals, and a reconnect snapshot or a dropped delta re-reads the newest page (a gap replaces the list); 10,000 entries kept, 5,000 held; virtualized above 200; the histogram in four bands on status colors, ~8 px a bar, re-read every 15 s while live, click or drag to zoom; URL parameters as the API's; saved searches keep the filter, not the range; / focuses the search; Dialog gains size="wide"; the device page links to its logs. |
newest at the bottom (terminal style); details in a side panel; context replacing the list; sources ANDed; filtering live entries on the server per subscriber |
| D-061 | Events and alerts (M2.5b, 2026-11-02). Owner's choices: split vertically: b1 events end to end (derivation, storage, API and WS topic, the events page, the dashboard's card, logs around an event) | b2 alert rules and notifiers with their settings; user alert rules match metrics and events: a threshold on any stored series for a duration, or an event match (kind, node, level, count in a window), built-in rules being presets of the same two kinds; email goes over SMTP with lettre and rustls (STARTTLS or TLS, the password in the vault). Agent's calls (reversible): a pure L0 crate events derives them (Deriver, Folder); types in model::events (EventRecord with what: EventKind tagged by kind, four levels critical/warning/notice/info, seven categories); conditions (offline, degraded, flash, a WAN link, a mesh link down or weak) raise once the bad state lasts (30 s, 60 s, at once, 10 s, 30 s, 2 min) and clear with how long they lasted, keyed node:<id>/<what>, and resume after a restart from the store's latest event per key; a mesh link between adopted nodes that vanishes from the topology while both are online is down (batman-adv forgets lost neighbours), forgotten after 24 h; reboots from the boot time moving by over a minute, read only from a fresh system set; flash low under 10 % free, fine from 15 %; WAN address changes over IPv4 addresses and delegated prefixes; clients join, leave and are new only once their node has been online a minute, and don't leave a node that went offline; log-patterns.toml gains event = "<category>" (13 patterns), and a source's repeats of a pattern within 5 min fold into one record with a count; adoptions and failed jobs from the job runner; the controller's start is an event; events are kept 60 days after their last repeat and at most 200,000. UI (M2.5b1): /insights/events newest first in days, filters in the URL as the API names them (default range 24 h, up to 60 d), category toggles, a level and a device select; arrivals go straight to the top (events are few: no hold-and-release as on the logs page) and a fold replaces its record by id; at most 5,000 kept; "Ongoing" marks a raised condition nothing shown cleared, on live ranges only; the logs around an event are ± 5 min on its device; titles and details in words live in lib/events.ts (shared with the dashboard's card, which follows events?min=notice and shows six); clients that no node places are new only after the recorder's first minute (a demo run showed every leased client "new" at start). |
| D-062 | Alerts (M2.5b2, 2026-11-02). Owner's choices: split vertically: b2a alert rules and in-app alerts end to end | b2b notifiers end to end (ntfy, webhooks, email, their settings and a test send); an alert fires, then resolves on its own, and can be acknowledged (reminders stop, it dims in the bell until it resolves); silencing while a job runs on a node (and 5 min after) plus maintenance windows (a time range, all nodes or some, a note) and a quick snooze, silenced alerts still recorded and marked; a bell in the top bar (count, popover of active alerts) and /insights/alerts (Active, History, Rules), notifier settings under Settings › Notifications (b2b), active alerts on the dashboard's recent events. Agent's calls (reversible): see features/observability.md, Alerts. |
| D-063 | Notifiers (M2.5b2b, 2026-11-03). Owner's choices: split b2b1 the backend (the notify crate, storage, dispatch, API, test send) | b2b2 Settings › Notifications; filters on the notifier (a least level, optionally some devices, resolutions on or off; rules stay unaware of notifiers); reminders per notifier (off, or every 15 min to 7 days; the UI offers 1 h, 4 h, 12 h, 24 h; 4 h by default) while an alert fires neither acknowledged nor silenced; webhook presets plus a custom template (Discord, Slack, Teams Workflows Adaptive Card, Home Assistant, uniwrt's JSON; custom JSON with {{placeholders}} and headers). Agent's calls (reversible): a new L1 crate notify (formats, transports, and the pure Policy), the engine's Notifications running it; HTTP with hyper-util and hyper-rustls (ring, Mozilla's roots from webpki-roots, HTTP/1.1; leaner than reqwest, whose rustls feature brings aws-lc-rs and the platform verifier), 15 s per exchange, no redirects; SMTP with lettre (STARTTLS required or TLS, never in the clear; webpki-roots, ring); licenses CDLA-Permissive-2.0 (webpki-roots) and 0BSD (quoted_printable) allowed; secrets in the vault as one JSON value per notifier (notifier/<id>): a webhook's URL (services put tokens in it) and headers, ntfy's token or password, SMTP's password; the API takes them (left out: kept, null: removed) and shows only which are set, a URL as its scheme and host; only those its kind uses are kept; policy: an alert is sent once on opening or reopening to each enabled notifier that admits it, unless silenced or acknowledged (one silenced at opening is sent when it no longer is); resolutions go to those that heard of it; an alert resolved because its rule changed tells nobody, and its replacement within 2 min takes over what was sent; a notifier added or changed hears only of alerts that open from then on; after a restart the alerts firing count as heard of; delivery: a worker per notifier gathers what arrives within 2 s into one message (a digest of up to 25), retries what may pass (timeouts, connection errors, HTTP 429 and 5xx, transient SMTP errors) after 15 s, 1 min and 5 min, and records the last success and failure on the notifier; ntfy over its JSON publishing (priority 5 critical, 4 warning, 3 notice and resolutions, 2 info), Discord with mentions disabled; links to the alert from the notifications setting link_url (the controller's address as people open it; none when unset); at most 20 notifiers, 10 headers and 10 recipients; the test (POST v1/notifiers/test) sends at once, once, and answers 502 with why. |
no split; everything to everyone; rules pick notifiers; both; one global reminder interval; no reminders; presets only; a template language (minijinja); reqwest; secrets in the notifier row |
| D-064 | RF insight (M2.6, 2026-11-03). Owner's choices: one WP, not split (an earlier session had left backend and UI uncommitted; this one reviewed, tested and finished both); background scans every 4 h while the radio is idle (its stations, mesh peers included, under 500 kbit/s in the two latest samples; a busy radio retries in 10 min), instead of the spec's off-peak window: data stays fresh through the day and a scan never interrupts traffic; the interval is a setting (1 to 24 h), and admins scan at any time. Agent's calls (reversible): the agent's scan op runs scan.uc in its own process (its own nl80211 socket, so the agent's ticks cannot read the scan's replies) and returns raw information elements, parsed by the controller (facts/ies.rs); one scan at a time across the site, 30 s apart; scans kept 30 days as JSON in rf_scans (migration 0012), the latest per radio rebuilding the report after restarts; a pure L0 rf crate builds the report (interference weight(signal) × overlap, logistic weight centred on −75 dBm; lookalikes are networks named like the site's but not adopted); the RSSI matrix prefers a live mesh link's signal over scans; channel changes are events marked radar within 2 min of a dfs-radar line, and congestion events use the channel-busy rule's thresholds (80 % for 15 min, clear at 65 %); the RF page shows channels in use by default, and the spectrum spaces its axis numbers by distance (hwsim lists 5.9 GHz channels 5 MHz apart). |
an off-peak window only; idle-gated plus a nightly scan of every radio; split backend | UI; discard and restart |
| D-065 | Desired state and revisions (M3.1, 2026-11-03). Owner's choices: model, store and API plus a read-only history page (split a backend | b history page; the pending-changes tray and rollback come with M3.4's review UI); one linear history where every save is an immutable revision (a save names its base and is refused with 409 when another came first; discard and rollback are restores, new revisions copying an older document); schema v1 is a skeleton of every section with the fields M3.3 renders (WAN, services and radio policy start minimal; later WPs extend them through forward-only migrations); a new site starts from the defaults as revision 1 (10.0.0.0/16, a random ULA, the default LAN at 10.0.0.1/24, no WLANs). Agent's calls (reversible): the document lives in model::site (with serde_json now a model dependency, for the diff and migrations); networks and WLANs have ids [a-z][a-z0-9_]{0,11} that name UCI sections and br-<id> (superseded by D-067: one br-lan); nodes are keyed by NodeId, and a node's role stays on its inventory record; each secret value is sealed under a new random vault name config/<32 hex> and committed in the revision's transaction, so old revisions and restores keep their values (old values stay in the vault while revisions refer to them; revisions are kept for good for now), and a document may refer only to its parent's, its restored revision's or its own new secrets; validation splits errors (refuse, 422 with issues by path) from warnings (shown), and 192.168.1.0/24 is a warning, not an error, so a brownfield LAN on it can be imported; the country starts unset (a wrong one is illegal on the air) and warns once radios broadcast; the diff matches list items by id, node, radio or mac, and a reorder alone is no change; a save or restore that changes nothing makes no revision; no WS topic for revisions yet (the history page refetches). |
a mutable draft committed on apply; per-user drafts; the full spec's schema now; only M3.3's sections; no revision until import |
| D-066 | The uci crate (M3.2, 2026-11-03). Agent's calls (reversible): The parser ports libuci's (strict mode, its in-place quirks and error positions included) rather than a grammar of our own, held to uci of both releases by conformance fixtures (scripts/uci-conformance.sh, run in CI's transport job), so a node's file reads the same here and on the node. Comments are dropped, as libuci drops them on every commit; the serializer writes exactly what uci commit writes (it quotes a type with '"#;\ where libuci can't read its own output). Text is UTF-8: callers decode a node's files and refuse what isn't. Diff semantics: order counts only among the sections of one type (firewall rules), not between types or among options; named sections pair by name (a type change is a change), anonymous ones by type, equal ones first (a longest common subsequence after trimming the equal ends, greedy past 1 M cells), then the most similar in order (half or more alike), then leftover equal ones as moves; moved marks pairs outside the longest increasing run of old positions; apply inverts a diff and refuses one taken from another package. Identifier rules moved from model into uci (model now depends on uci), without the 64-byte cap the model had, which libuci doesn't have. |
a hand-written grammar; keeping comments (a lossless CST); positional matching of anonymous sections; order between types as significant |
| D-067 | Render v1 (M3.3a, 2026-11-03). Owner's choices: the board's layout comes from board.json in the facts (board.layout: LAN and WAN ports, MACs, the swconfig switch with its CPU ports, compat_version; and each radio's path), as OpenWrt's config_generate reads it, rather than from the node's current UCI or a model table; the mesh's basics render now (batman-adv over an 802.11s point, bat0 a bridge trunk, BLA on wired trunks), M5.3 adds backhaul selection and rotation; a firewall zone per network with forwardings from the purpose-seeded matrix and the site's overrides, plus OpenWrt's stock rules and DHCP, DNS, DHCPv6 and neighbour discovery to the gateway from zones that may not reach it; M3.3a in one part; one VLAN-filtering br-lan on every node (interfaces on br-lan.<vid>, ports and bat0 as trunks, swconfig's <cpu>.<vid> as access ports; a plain bridge while every network is untagged), superseding D-065's br-<id>, for DSA offload and the stock shape; new sites' default LAN gets IPv6 from both the ULA and the delegated prefix (delegated only without a ULA), as OpenWrt ships. Agent's calls (reversible): render takes the vault's values and the node's current packages and is pure; it renders only managed packages and refuses one without a renderer (an empty dropbear would lock SSH out); values the node generated are carried over (the DHCPv6 DUID, compat_version, led sections, odhcpd's path options), not dropped by whole-file ownership; errors (a missing WAN port or secret, the mesh or WPA3/OWE without the node's support, a fixed channel the radio lacks) refuse the node, while WPA2/3 transition falls back to WPA2 without SAE and channel auto without ACS keeps the current or a non-DFS channel, with warnings; the mesh takes the band's fixed channel, else 1/36/37 with a warning, and on tri-band nodes the high-band radio, whose sibling keeps off its channel; APs bridge their WAN port, take DHCP on the management network and get firewall defaults that accept input; fw4 accepts replies globally, so block and established both render as no forwarding; WLAN sections are uw_<wlan>_<band>, a second radio of a band …_<band>2; RA with SLAAC and stateless DHCPv6; the gateway serves NTP; network ids loopback, wan, wan6, wan_6, bat0 and uw_… are reserved; time zones must be tzdata names, turned into POSIX strings by an embedded table (crates/model/data/tz.tsv, just tz); colliding ts-rs names were renamed (IssueSeverity, RadioChannel, UciPackage) and arch-check now refuses two Rust types with one TypeScript name. |
the node's current UCI or a model table for the layout; the mesh in M5.3; stock lan/wan zones only; a bridge per network |
| D-068 | The system harness (M3.3b, 2026-11-03). Owner's choices: OpenWrt rootfs containers on every commit (the CI job system, which gates releases), not QEMU images nightly; an ISP container linked by a veth pair that serves DHCP untagged and on VLAN 201, and PPPoE, so each WAN kind comes up end to end; Wi-Fi by re-rendering the hwsim lab after its own tests (node a the gateway, b the AP; client c must lease again); one part; and, as the dev machine's running kernel had lost its module tree, VLANs, PPPoE, fw4 and Wi-Fi iterated in CI on a temporary branch. Agent's calls (reversible): a test crate system drives the containers through docker exec (the working tree's agent for facts; values reach the shell as arguments); the containers have --network none and only veth links, so no DHCP or RA reaches the host; uci export must parse back to exactly the rendered package; scripts/test-system.sh probes the host's kernel (8021q, pppoe, nftables), and tests that need what it lacks skip with a note locally and fail in CI; the gateway's DHCP pools render force '1': dnsmasq's init refuses a segment where another DHCP server answers (an AP still on its stock config did exactly that in the harness), leaving the site without DHCP; apply stops and disables services that go off before reload_config and waits out procd's trigger delay, and checks wait for the new config's own values (features/lifecycle.md); in the lab, the site's LAN keeps a's Docker address with DHCP and IPv6 off, the clients' WLAN is on VLAN 99, and b leaves Docker's network for the test, so c's 5 GHz lease can only cross bat0. |
|
| D-069 | Transactions and management mode (M3.4a, 2026-11-04). Owner's choices: M3.4a splits again (a1: the node side and management on/off; a2: the apply job; b stays the review UI); the transaction tool is POSIX sh with procd (/etc/uniwrt/txn, from node-agent/txn/; /etc/init.d/uniwrt-guard at START=13, after logd and before the network, rolls back an open transaction at boot, and while one is open procd runs its dead-man timer and its reload as the guard's instances, so neither depends on the SSH session or the agent); applies commit fleet-wide in two phases (M3.4a2: every node applies with its timer armed, leaves → gateway, then all are verified over fresh connections and confirmed only if every check passes; otherwise each rolls back by itself, so changes that only work together, such as the management VLAN, can be applied); management is an explicit per-node action (PUT v1/nodes/{id}/management, admins only, audited; the manage job installs the footprint and records the node as managed; unmanage removes it and leaves the config; an open transaction blocks both). Agent's calls (reversible): the controller runs the tool over exec, not through an agent txn op (it works without ucode, and the agent dies with the session the reload may cut); a transaction is staged in RAM (stage, put), checked with uci -c <stage> show (and services to start must exist), then apply snapshots the packages it replaces and the services' enablement into /etc/uniwrt/journal/<id>/, arms the timer (30 s – 1 h, by uptime, so clock steps don't matter), stops the services that go off, installs each package atomically keeping its mode, runs fw4 check before anything reloads (rolling back at once if it fails: fw4 can't read a staging directory), enables the services that go on, and answers before procd's reload instance runs reload_config and starts them; confirm works only once applied, extend moves the deadline (for the two-phase commit), and a rollback stays open (rolling_back) until its reload ends, so a reboot halfway rolls back again from the same snapshot; the footprint (tool and guard) is installed by version (the SHA-256 of its SHA256SUMS, as the agent's bundle) and kept by /lib/upgrade/keep.d/uniwrt; jobs still don't resume (D-042 stands; ROADMAP's "resumable" is dropped): an interrupted apply leaves unconfirmed nodes to roll back by themselves; NewJob carries its node when known, so a manage job's 202 names it. |
O-1 · How to carve the 10.0.0.0/16 address space (needed by M4.2)
- a) A /24 per network, with the third octet equal to the VLAN ID: LAN 10.0.0.0/24 (untagged), IoT 10.0.30.0/24, Guest 10.0.40.0/24. UniFi-like and easy to remember. ← recommended
- b) Flat: the LAN is the whole 10.0.0.0/16 (gateway 10.0.0.1), and extra networks live outside it.
- c) A larger LAN at 10.0.0.0/22 (1,022 hosts), with /24 VLANs at 10.0..0 for VLAN IDs ≥ 4.
O-2 · Client steering (needed by M5.2)
- a) usteer with controller-managed profiles ← recommended for phase 1
- b) Controller-driven smart steering using global client history (phase 2, built on a)
- c) None; rely on 11k/v hints only
O-3 · batman-adv routing algorithm (needed by M5.3; M2 observe data can compare)
- a) BATMAN_IV: packet-loss metric, the most proven ← recommended
- b) BATMAN_V: throughput metric, better for mixed wired/wireless meshes when drivers report expected throughput
O-4 · 32-bit ARM variants
- a) armv7 (linux/arm/v7) only ← recommended
- b) Also armv6 (Raspberry Pi Zero/1, linux/arm/v6)
O-6 · Extra distribution channels (default: none until M9)
- a) An OpenWrt package for running the controller on an arm64/x86_64 gateway
- b) A Home Assistant add-on
- c) Unraid/TrueNAS templates and a Proxmox LXC
O-7 · Optional AI troubleshooting assistant (default: backlog) An opt-in panel that explains incidents from logs, metrics and topology.
- a) Bring your own API key (for example, Claude)
- b) A local model (Ollama)
- c) Not at all
O-8 · PPSK: per-device Wi-Fi passwords that map devices to VLANs (default: backlog, after M5.1)
O-9 · Home Assistant/MQTT integration: presence from client joins, alerts (default: backlog)