DocsWorking on Yarrix
Decisions
Architecture decisions for Yarrix, with the alternative each one rejected.
| # | Decision | Rejected |
|---|---|---|
| D-01 | Hub in Bun + TypeScript, SQLite (WAL) | Postgres (extra service for single-hub scale) |
| D-02 | Agent and CLI in one Rust static binary | Bun agent (footprint), Go (outside the chosen stack) |
| D-03 | The agent dials out over a WebSocket with a per-host token | Hub-initiated SSH to agents (needs inbound reachability, fails for sleeping or NATed hosts) |
| D-04 | The agent executes no hub-sent commands; local allow_paths | Remote-configurable commands (a compromised hub would become fleet RCE) |
| D-05 | Config in YAML files, state in SQLite, settings in env. UI writes back with comment-preserving round-trip and an optional git commit, never a push. | Config in DB (loses version control) |
| D-06 | Secrets via ${env:} / ${file:} only | Built-in secret-manager clients (vendor lock-in), ${cmd:} (UI-to-RCE) |
| D-07 | The alert retry queue is a SQLite table written in the incident's transaction | A separate file queue (two durability stories) |
| D-08 | Chat products are supported via the template format, not bespoke adapters | Per-product adapters in core |
| D-09 | Terminal = system OpenSSH in a Bun.Terminal PTY. No hub-side persistence; persistence comes from an idempotent startup command. | ssh2 under Bun (unsupported), node-pty (leak patch, broken wrapper), agent shell (breaks D-04) |
| D-10 | Startup command: global default plus per-host override. Absent = inherit, "" = login shell. Wrapped in exec "$SHELL" -lc. No per-session arbitrary commands. | Per-session free-text command (crafted-link risk) |
| D-11 | Auth = pluggable providers (local, Supabase, Tailscale) with one Yarrix-side role model | Provider-specific roles |
| D-12 | License Apache-2.0 | MIT (no patent grant; Apache is the norm for infra tooling and stays compatible with xterm.js MIT and Source Serif 4 OFL) |
| D-13 | Design tokens and styles re-implemented in packages/ui from documented token values. Source Serif 4 is bundled under OFL with its license file. Phosphor icons (MIT). | Vendoring unlicensed stylesheets |
| D-14 | Ping tokens live in the DB (optionally pinned via a secret ref), not in config files | Ids in config (leaks when the config repo is shared) |
| D-15 | Default port 7790, loopback by default | |
| D-16 | yarrix run as the wrapper. A hc-run symlink name works too. | Shipping a separate wrapper binary |
| D-17 | SMTP client = nodemailer 10.x (MIT-0, active: last release 2026-11-07, Node >=20). It handles STARTTLS, implicit TLS, AUTH PLAIN/LOGIN and certificate validation with a custom CA, and builds the multipart message. The hub resolves the SMTP host itself, checks every address, and hands nodemailer the checked IP with the original name as TLS servername, so nothing is resolved twice. Checked under the pinned Bun 1.4.4: plain SMTP and implicit TLS with a private CA both deliver against a loopback test server (apps/hub/test/email.test.ts). | emailjs (smaller, active too, but no multipart/alternative builder or IDN handling, so more hand-written MIME), a hand-rolled SMTP client (a mail-header and dot-stuffing bug class we would own) |
| D-18 | macOS GPU utilization comes from the I/O Registry IOAccelerator Device Utilization % counter (ioreg), no root and no private API. See "GPU on Apple Silicon" in platforms.md. | IOReport (private, undocumented, per-state residency rather than a busy percentage), powermetrics (root) |
| D-19 | Agent self-update keeps D-04: the hub sends only a version and an id, "when" and never "what". The agent installs a build only if its manifest carries a minisign signature from a key compiled in or listed in the machine's own agent.yaml, the version is newer, and size and SHA-256 match the signed manifest. Staging lives in the state dir and the launcher execs the staged build; a trial run that crashes or cannot reach the hub rolls back automatically. Containers and unsupervised agents refuse. | The hub sending a URL or binary (a hub takeover becomes fleet RCE), replacing the running binary in place (fails under ProtectSystem=strict, no rollback), a hub-held signing key |
| D-20 | Update mode is agent_updates.mode = automatic, one_click (default) or notify: a hub default in yarrix.yaml and a per-host override, both guarded fields (admin session only). Sam's rule: it is up to the user. | One global switch, or a default of automatic (changes running software unasked) |
| D-21 | Minisign is the signature format; the trusted public keys are a build-time input (YARRIX_RELEASE_PUBKEYS) plus the agent's own update.trusted_keys. The project's public key is committed (minisign.pub) and official builds and hubs trust it by default. Maintainers sign locally, offline from CI (scripts/release/sign-release.sh); the key is never in CI. CI uploads unsigned files to a draft release, which is published only once signed, and unsigned releases are simply not offered. No private key is committed. | Ed25519 with a custom envelope (no ready verification by hand), GPG (heavy), trusting the hub's TLS alone |
| D-22 | Automatic mode starts updates only through the same version-only update as a click, and only one canary host first, never more than max_concurrent at once, relaying hosts last, inside the optional window. Two hosts failing on one release pause automatic updates and Update all for that release until an admin resumes; one automatic retry after six hours. Sam's rule: the user chooses the mode. | Updating everything at once (a bad release reaches the whole fleet before anyone sees it), random jitter instead of a concurrency cap, pausing on the first failure (one dead disk would stop the fleet) |
| D-23 | The reinstall over SSH for agents that cannot update themselves is one-click only: it reuses "Install it for me" with a password or sudo password typed by an admin, mints no new enrollment token and leaves the host's settings alone. Automatic mode never uses SSH. | Storing SSH credentials so the hub could reinstall unattended, minting a fresh token for an agent that is already enrolled |
| D-24 | The Windows agent ships as an NSIS setup.exe that wraps yarrix agent install (one pasted setup code hub#token, silent mode with exit codes, an Add/Remove Programs entry, an uninstaller). It is built in package.sh on Linux/macOS/CI, unsigned for now (a hook is left for signing). The installer hands the token to the agent through its environment, never a command line or a path. | WiX/MSI (builds only on Windows; MSI file ownership fights the agent's self-update swap; revisit only if someone needs Group Policy), Inno Setup (Windows-only compiler), the agent as its own GUI (a dialog inside a SYSTEM binary) |
| D-25 | Windows setup uses a one-time six-digit PIN shown as NNN-NNN (15 minutes, one host, one use) exchanged on the installer's page for an enrollment token, and the hub address is stamped into the download's file name (_hub-<base32>), never into the bytes. The hub stamps only its own address, never a value from the request. A PIN is stored as a SHA-256. Six digits is a small space, so guesses are limited per address, by a hub-wide budget (30 wrong guesses burn every unused PIN), and both counts are kept in the database across restarts. The first-run setup PIN works the same way with a total cap of 100 wrong guesses, after which only yarrix-hub auth setup-pin makes a new one. The short /connect page replaces the long download URL. | A passcode used as the bearer token itself (it would enroll from any PC, and a leaked passcode would stay useful), data appended to or tagged into the exe (breaks the SHA-256 and the signature), certificate-table tags (unauthenticated data in a signed file) |
| D-26 | The agent PIN is exchanged with SPAKE2 (RFC 9382, P-256 with the hub as B). The PIN never crosses the wire in either direction, and each start is one counted guess. The agent has no plaintext route: a 404 from a spoofed hub stops the enrollment instead of sending the PIN. | A PIN sent over TLS only (trusts the certificate chain, and fails on plain-HTTP LAN hubs), a plaintext fallback for old hubs (a spoofed hub could force it), a PIN-derived secret sent in the request (an offline guess target) |
| D-27 | Every PIN start counts toward the hub-wide budget (30 in 15 minutes burns the live agent PINs), including a start that proves the right PIN. An unauthenticated LAN client can therefore deny enrollment by spending the budget; the admin mints a new PIN. Accepted: guessing resistance wins over enrollment availability during an attack. | Per-address limits only (a six-digit space falls to rotating addresses), refunding starts that prove the PIN (a second counter for one enrollment per 30 starts) |
| D-28 | The first run has no PIN: whoever opens the setup page first, within one hour of the hub starting (CLAIM_WINDOW_S), chooses the admin username and password. The window reopens on restart, and yarrix-hub auth setup-pin opens a PIN-gated claim at any time. The claim re-checks "no admin yet" inside its transaction, refuses a Host that is not an IP, localhost, a single-label name, *.local, *.ts.net or the public URL (DNS rebinding), and the hub, /connect and the setup page all say anyone who can reach the hub can claim it. Recovery is a single-use 15-minute PIN from the hub machine that signs every session out. | A PIN printed in the log for every first run (the owner had to find the log and the right address first), a bootstrap token in the environment, a window with no end |
| D-29 | Local passwords use OPAQUE (RFC 9807, @serenity-kit/opaque, ristretto255, the memory-constrained key stretching profile run in the browser) so the hub never receives a password and a stolen database needs an offline guess per record. Each sign-in start counts as one failed attempt, because a client can test a guess against the response without calling finish; a finished sign-in clears the count. 5 from one address lock that address and username for 15 minutes (persisted), 100 in a row switch the password off until recovery, and the owner can be forced into recovery by anyone who reaches the hub (accepted). OPAQUE does not encrypt the connection, so plain HTTP still shows the Not encrypted banner and the docs say a hostile network can alter the sign-in page. | Sending the password over the connection and hashing it on the hub (the plaintext crosses the network), SRP (weaker guarantees, no standard), counting only finished attempts (guesses would be free) |
| D-30 | Telegram and Pushover are typed destinations, not format: template: Pushover takes a form body a JSON template cannot produce, Telegram puts the bot token in the URL path, and both answer with ids the adapter needs (Telegram message_id for replies, the Pushover receipt for emergencies). The user brings their own bot and app; Yarrix ships neither. Same queue, retries, rules and events. Amends D-08: a push or paging product whose API a template cannot express safely gets a typed destination; chat products with plain JSON webhooks stay on template. | format: template for both (no form encoding, and a token written into a URL) |
| D-31 | Pause is runtime DB state merged into the disabled set. A global pause needs an admin and an end of at most 7 days. An operator's pause needs an end, and the paused time on that scope in the last 30 days (active, resumed and expired pauses all counted, clipped to the window) plus the new pause must total at most 30 days; past that, or with no end, it needs an admin. Every pause with no end is counted in the banner on every page. | Pause as a config edit (needs a write and a commit to power a box off); open-ended global pause; open-ended operator pause (it switches monitoring off for good without anyone noticing) |
| D-32 | Dependencies suppress notifications, never incidents. An object whose parent (its host, or a depends_on target) has an open critical incident sends nothing of its own: its incident is recorded with the parent named (depends on host atlas (down)), and it is announced after another group wait once the parent is up. Its own uptime history still shows its state (a down host's services are down); the hold only changes who is told. An object with a parent waits behavior.group_wait (default 90s, 0s turns it off) before its first notification, and an object with no parent sends at once. A host with children waits too, so its alert can name the children that open within that window and the ones it covers; children that open after it went out are suppressed but not named. An outage that clears inside the wait is recorded but never announced, the same as Alertmanager's group_wait; group_wait: 0s restores immediate alerts. A dependency cycle is a load error naming the loop, and its links are ignored at runtime so a loop cannot hold an alert back. Pauses win: a paused object resolves as paused whatever it was waiting for. Grouping (behavior.group_by_host, default on; alert_grouping: false on a host turns it off for that host) covers the other case, a host that is up while several things on it fail: apps, backups and heartbeats of one host that leave their group wait together go out as one incident.grouped message per destination ("atlas: 6 problems (3 critical)", worst first) when two or more are bound for that destination, and a lone one is sent as itself. Every incident keeps its own row, timeline and ack. A destination receives only the incidents its own rules routed to it, and one whose events list leaves out incident.grouped gets the individual messages, so nothing is lost for a receiver that cannot parse the new event. Silences, snoozes, maintenance, rule holds and quiet hours apply to each incident before it is grouped. Incidents opened within behavior.group_interval (default 5m) of a group's message join it as one follow-up ("atlas: 2 more problems"); later ones are single messages again. Recoveries inside that window are held and go out as one "atlas: all 6 resolved" when every member has recovered, or one by one when the window ends first, so a recovery is never dropped. group_wait: 0s sends at once and so groups nothing. A host that is down is still covered by its own alert, not grouped. | Dropping child incidents (loses history and availability); holding every alert (adds latency where nothing can be grouped); honouring a cycle (two hosts waiting on each other would never alert); grouping by editing the individual messages after the fact (a receiver that cannot edit would show nothing), or sending only the group and dropping the per-incident message for a destination that cannot parse it (a lost alert) |
| D-33 | Availability comes from a change-only state_log (domain, object_id, ts, class) written by the alert engine's tick in the incident transaction. A class is up, degraded, down or excluded (unknown, unobserved, asleep, disabled, inside a config maintenance window); figures are (up + degraded) / (up + degraded + down) over the observed time, excluded time is reported beside the figure, and under 50% observed reads as not enough data. An object that leaves the config gets an excluded row. Kept 400 days; a purge keeps each object's newest older row as the baseline. Ping retention is 35 days so a month of heartbeat runs is complete. The time between the last tick before a stop and the first tick after a start is excluded (a stopped hub credits no uptime and charges no downtime). availability.keep (default 400d, at least 35d) sets the retention. The per-day strip uses UTC days, and only GET /apps/{id} carries availability_30d among the detail documents; the others are read through /availability (the demo-fixture contract compares their shapes). | Deriving it from incidents (no asleep or paused time) or from check rollups (apps only, 30 days) |
| D-34 | Domain expiry is read over RDAP (RFC 9083), finding the registry through the IANA bootstrap file (RFC 9224). The hub fetches the bootstrap only while a domain check exists, keeps it in its database for 7 days, and uses a stale copy if IANA is unreachable. name is the registered domain as given (no Public Suffix List). A check runs every 12 hours and never faster than hourly. Anything the registry does not answer (no RDAP for the TLD, HTTP error, no expiration event) is unknown, never red. | Parsing WHOIS (no standard format, so a scraper we would own), working out the registrable part of a URL (needs the Public Suffix List), fetching the bootstrap on every run (rate limits, needless outbound traffic) |
| D-35 | An AI agent's API key is an ordinary yx_ token with the operate scope (read-write) or read (read-only). operate acts as an operator, never an admin. A key acts only through named things an admin defined: a check, a backup, or a command declared in the host's agent.yaml; the hub sends the agent the name and the agent runs its own argv, with no shell. A key's mutes are capped by auth.token_limits.max_mute (24h), refused when the body is oversized or unreadable, and announced as system.action to the default destination. Every attempt is audited. No migration is needed. | A separate agent-key table (a second credential store), a free-form command endpoint (a remote shell), letting a key send its own argv (the hub would no longer define what runs), a cap enforced only in the handler (the gate and the handler could disagree) |
| D-36 | Status pages and badges are opt-in and guarded. With no status_pages there are no /status routes and with no badges no /badge routes: both answer 404, as if they did not exist. status_pages and badges are guarded fields, so only an admin signed in to the web app can change them. A page shows only the items it lists, by the label the admin wrote: a state word and, if asked, the 30-day figure. It never shows a host name, id, address or reason, unless the page sets show_reasons (the hub warns when that is on a public page). A page is viewer (a signed-in session; a token is not one) unless it says public. A badge URL carries a key the hub minted (22 base62 characters, kept in badge_keys like a ping token, D-14) or a pinned one from a secret reference. Taking a badge out of config, or rotating it, kills its URL. The hub reads the fleet once per 15 seconds for all visitors, and limits each address (a whole IPv6 /64 counts as one) to 120 page reads, 300 badge reads and 30 wrong badge keys a minute. Pages are server-rendered HTML with one inline stylesheet that the page's CSP names by hash, and no script. | Serving the viewer UI with no sign-in, since it holds every host and reason; a slug-only badge URL, which anyone could guess or enumerate; a page that lists everything with filters |
| D-37 | A heartbeat's keywords turn a success ping into a failure by plain substring match on the raw body (before masking), never by regex, and never turn a failure into a success. The match is recorded as a failed ping with its reason. | Regular expressions (ReDoS on an unauthenticated endpoint); matching the masked body (a mask could hide or create a match); letting output rescue a failed ping |
| D-38 | Heartbeat description, owner, runbook and tags are plain config fields, not secret. runbook accepts only http(s) URLs, tags take part in routing like an app's, and owner and runbook ride in the alert payload so the page carries what to do. | Any URL scheme for the runbook (javascript: in an href); a separate metadata store |
| D-39 | Every setting in yarrix.yaml, defaults.yaml, alerts.yaml, host files and checks/*.yaml can be edited in the web app, through the one config write path (PUT /config/files/{path}). No per-field endpoints. This reverses the earlier "role rules are edited on disk only". Creating checks/<name>.yaml and removing hosts/<id>.yaml (history kept) go through the same path. | Keeping auth disk-only (Sam asked for all of it), and per-setting endpoints (a second validation and audit story) |
| D-40 | A secret written through the API must be a reference: ${env:}, ${file:} or ${secret:NAME}, except a webhook or Telegram API URL whose literal text carries no secret. Changing a reference is a guarded field (admin web session only). Literals already on disk keep working and are only judged when changed. Typed values live in the hub's encrypted store: AES-256-GCM under a random data key, the name as AAD, kept in secrets.json in the data folder, never in the config folder. The data key is wrapped by the hub key (YARRIX_SECRET_KEY, else secret.key, mode 0600, made on first use) and by a recovery key (160 bits, shown once, never stored). A missing or wrong hub key reports secret NAME can't be decrypted at load, it does not crash. | Literals with a warning (the old Alerts tab), plain 0600 secret files (plaintext on disk, Sam rejected them), and an external secret manager (vendor lock-in; D-06) |
| D-41 | The recovery key also redeems at /recover as an alternative to the CLI setup PIN, under the same attempt limits, and re-keys a store whose hub key was lost. It is reusable, audited, and an admin can regenerate it. An auth edit made through the API that would leave the saving admin without an admin role or a way to sign in, or stop the hub starting, is refused; other people's loss of access is previewed and confirmed. Disk edits plus auth setup-pin stay the break-glass. | "At least one admin anywhere" (a stored admin with a forgotten password satisfies it and helps nobody), blocking any change that reduces someone's access (it would stop ordinary offboarding), and making the recovery key single-use (the key is the only copy a person keeps) |