Wheelhouse docs

Architecture decision records#

Three decisions in this product were written down at the moment they were made, because each one had a plausible alternative and each one constrains everything built afterwards. They live in docs/adr/ and follow the usual shape: the context that forced a choice, the choice, and what it costs. This page summarises all three and says where they bind. Read the record itself before changing anything it decided — a decision reversed without reading the reasoning is a decision made twice.

A new decision that changes the product's architecture or scope belongs in docs/adr/NNN-*.md, per the cross-repository protocol in AGENTS.md. Platform and bench decisions belong in wheelhouse-infra instead.


ADR-001: the desired-state file#

Accepted 2026-08-30. docs/adr/001-desired-state-file.md

What forced it#

PLAN.md §6 promised a declarative file that the agent reconciles against the router. Implementing it literally forced two questions the plan had not answered: what format, and who may move the router. The second is the real one: the product's first principle is that the Commit Bar is the only thing that commits, and a loop that commits on a timer contradicts it — but a file that can only ever be staged cannot be applied unattended, which contradicts the plan's own "re-applies cleanly after reboot".

What was decided#

Both formats, sniffed. parseDesiredDoc decodes JSON when the bytes start with { or [, and YAML otherwise — so agent.yaml holds YAML, an inline document in an HTTP body stays JSON, and a mislabelled file fails on its contents rather than on its name. That made gopkg.in/yaml.v3 the agent's third direct dependency. The record states the alternative that was rejected in one line: a half-parser is worse than a dependency.

Stage by default everywhere; commit is opt-in and always confirmed.

SurfaceDefaultEscape hatch
The Reconcile pageStage into the Commit Bar
POST /api/reconcileReturn the plan; apply: true stages
wheelhouse-agent planPrint commands, change nothingExit 2 when it found work
wheelhouse-agent applyStage through a running agent (--agent-url)--commit --confirm-minutes N
The --reconcile-file loopStage--reconcile-mode commit, with --reconcile-confirm (default 2 minutes)

What it costs#

  • A one-shot apply cannot stage into its own process. The staging area is the running agent's memory; a CLI that filled its own copy would report success and lose the work on exit. So staging is a POST /api/stage, and apply without --agent-url refuses.
  • A loop that commits is a footgun, handed over deliberately. It is off unless --reconcile-file is passed, it stages unless the operator says commit, and commit mode always uses VyOS' own commit-confirm so a wrong turn rolls back on its own.
  • The loop audits itself as actor: reconcile-loop, role: system, so the trail never blurs a human write with a machine write.
  • The loop will not double-stage: operations already in the Commit Bar are not offered again until a human commits or unstages them.
  • Deferred, and still deferred: file-watch triggers, per-host templating, more than one file.

ADR-002: OIDC#

Accepted 2026-09-02. docs/adr/002-oidc-client.md

What forced it#

Single sign-on had been outstanding since M2, and the development bench became a public host behind a forward-auth outpost, which made a browser sign-in two prompts. The record is blunt about the stakes: whatever this code accepts as an identity decides who may rewrite a firewall.

What was decided#

Verify with a library, not with us. coreos/go-oidc/v3 and golang.org/x/oauth2 became direct dependencies. Signature verification, JWKS caching and rotation, alg handling and the aud/iss/exp checks are exactly the part of OIDC where a hand-rolled implementation is quietly wrong — alg: none and key-confusion bugs live there.

One provider, configured by flags. Not a settings row in the database: an agent whose identity-provider credentials are editable through the UI that the provider gates has a bootstrapping problem, and a router is a bad place to hold a client secret.

Link by subject, provision by policy, never guess with email. A local user carries an oidc_subject, matched first. An unknown identity is provisioned when --oidc-provision is set, and otherwise refused with "this identity is not linked to a Wheelhouse account". --oidc-link-by-email exists but is off by default and refuses unverified addresses — an auto-link oracle turns provider self-service into a Wheelhouse privilege path.

Roles come from groups while a mapping is configured. --oidc-admin-groups and --oidc-operator-groups are authoritative when set, which is what makes the mapping worth having: a revoked group removes router write access on the next sign-in without anyone editing Wheelhouse. Unmapped identities get --oidc-default-role, which defaults to viewer, because seeing a router is not the same as being allowed to change it. The last-admin rule still applies — a group edit must not be able to lock the product out of its own router.

What it costs#

  • Password login, TOTP, API tokens and the break-glass token all stay. SSO is another door, not a replacement: an agent that can only be entered through a provider is one provider outage away from being unmanageable, which is the wrong failure mode for a router.
  • A local TOTP prompt is skipped for SSO sessions, because the provider did the second factor. --require-totp still governs password logins.
  • The redirect target must be a path. An SSO flow that honours an absolute URL is an open redirect with a session attached.
  • The session cookie is Secure when the client is on HTTPS, judged from the request rather than inferred from --trust-proxy. The first version of that rule broke the bench: the agent is reached over plain HTTP on its lab address as well as through a TLS proxy, the browser dropped the cookie on the HTTP origin, and a successful sign-in came back as "unauthorized" with nothing to explain it.
  • Two counters make the decision reviewable from /metrics: wheelhouse_oidc_assertions_total{outcome}ok, failure (the protocol broke) and refused (an identity was looked up and told no) — and wheelhouse_oidc_roles_total{action,role}.
  • Single provider, single issuer. Multi-tenancy wants per-provider storage, which is a bigger change with no user yet.

ADR-003: subscription licensing#

Accepted 2026-09-01. docs/adr/003-licensing.md. Supersedes an earlier open-core gate.

What forced it#

Wheelhouse is sold as a subscription, and the agent runs on customer routers that may sit behind strict egress rules or have no internet at all. So the licence check cannot depend on a live call, and the operator has to be able to issue and revoke keys by hand, with any expiry, without touching a database.

What was decided#

A licence is a signed token: WHL1.<kid>.<payload>.<signature>, Ed25519 over a small JSON payload carrying an id, a customer, a plan, features, a router limit, and issue, expiry and grace times. The agent embeds the server's public keys and verifies offline. Keys carry a kid so a rotation ships the new public key in the next release and keeps the old one for existing tokens.

The write plane is gated; reads are not. Every mutating route sits behind requireLicense and answers 402 with the reason. Sign-in, every page and every read endpoint work unlicensed. The reconcile loop pauses; plan works and apply needs a key.

Refresh daily, degrade slowly. An agent that can reach the server posts once a day and stores the token it gets back, whose expiry is the sooner of the paid-through date and 30 days. Unreachable, it keeps its token until expiry plus a seven-day grace. Revocation closes the write plane immediately.

Fleet is a feature in the payload, not a second key.

What it costs#

  • Every build carries the production public key by default; a development build overrides it with -ldflags -X main.licensePublicKeys=…. A build with no key can verify nothing, which is the safe failure.
  • The key is a credential for its own licence and is stored like one. The settings endpoints never return it.
  • Enforcement is honest about what it is. A self-hosted binary can be patched. The design lowers the cost of paying and softens the cost of lapsing; it does not try to make evasion hard.
  • The development bench needs a licence like any router.

What is not an ADR#

Plenty of load-bearing decisions live in comments rather than in docs/adr/, because they are local rather than architectural. The most consequential of them are worth knowing:

DecisionWhere it is recorded
Why the read cache exists, and its two freshness classesagent/cache.go
Why the primer does not refresh op-mode reads on a timeragent/primer.go
Why body limits sit in front of the muxagent/security.go
Why /health no longer carries the versionagent/main.go
Why /etc/os-release is owned by the flavorpackaging/iso/wheelhouse.toml
Why the EULA is replaced by a hook rather than a flavor entrypackaging/iso/branding/50-wheelhouse-brand.chroot
Why the boot-menu hook is prefixed 50-packaging/iso/branding/50-wheelhouse-brand.binary
Why the vyos-build pin lives in the source tree rather than in CIpackaging/iso/build-iso.sh
Why the units are enabled by symlinkpackaging/build-deb.py
Why every route page is lazily importedui/src/App.tsx

If you find yourself explaining one of these twice, that is the signal it should have been an ADR.

See also#

Checked against#

docs/adr/001-desired-state-file.md · docs/adr/002-oidc-client.md · docs/adr/003-licensing.md · agent/desired.go · agent/oidc.go · agent/license.go · agent/go.mod · PLAN.md · AGENTS.md

Updated 2026-09-02 development adr decisions architecture