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.
| Surface | Default | Escape hatch |
|---|---|---|
| The Reconcile page | Stage into the Commit Bar | — |
POST /api/reconcile | Return the plan; apply: true stages | — |
wheelhouse-agent plan | Print commands, change nothing | Exit 2 when it found work |
wheelhouse-agent apply | Stage through a running agent (--agent-url) | --commit --confirm-minutes N |
The --reconcile-file loop | Stage | --reconcile-mode commit, with --reconcile-confirm (default 2 minutes) |
What it costs#
- A one-shot
applycannot 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 aPOST /api/stage, andapplywithout--agent-urlrefuses. - A loop that commits is a footgun, handed over deliberately. It is off unless
--reconcile-fileis passed, it stages unless the operator sayscommit, 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-totpstill 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
Securewhen 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) andrefused(an identity was looked up and told no) — andwheelhouse_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:
| Decision | Where it is recorded |
|---|---|
| Why the read cache exists, and its two freshness classes | agent/cache.go |
| Why the primer does not refresh op-mode reads on a timer | agent/primer.go |
| Why body limits sit in front of the mux | agent/security.go |
Why /health no longer carries the version | agent/main.go |
Why /etc/os-release is owned by the flavor | packaging/iso/wheelhouse.toml |
| Why the EULA is replaced by a hook rather than a flavor entry | packaging/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 CI | packaging/iso/build-iso.sh |
| Why the units are enabled by symlink | packaging/build-deb.py |
| Why every route page is lazily imported | ui/src/App.tsx |
If you find yourself explaining one of these twice, that is the signal it should have been an ADR.
See also#
- Architecture — the shape these decisions produced.
- Working agreements — where a decision is expected to be recorded.
- Building the agent —
main.licensePublicKeys, and the dependencies ADR-001 and ADR-002 added. - Adding an endpoint —
requireLicensein practice. - Desired state · The desired-state file — what ADR-001 produced.
- What the licence gates — ADR-003, from the operator's side.
- Accounts and sessions — where an SSO identity lands once ADR-002's policy has accepted it.
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