ADR-120: Post-boot configuration surfaces and the config-plane ownership split
Context
ADR-119 §C committed the appliance to configuring itself after boot, through the console (DCUI) and the operator container, and named the work as follow-on:
Console DCUI +
operator.sh set-*(warm-reconfig, follow-on work): public hostname, TLS mode, DNS-01 credentials, Cockpit access — the knobs that today force a pre-boot seed.
That follow-on was never built. The intended experience — download the OVA, turn
it on, follow prompts on the VM console, then work in the web console — breaks at
"follow prompts," because there are none. kg-console.sh offers ten options, of
which exactly one (c, Cockpit CIDRs) changes configuration; the rest are status,
logs, lifecycle, and two shell escape hatches. Menu 4 (network info) prints the
interface table and tells the operator to "Change network settings via Cockpit".
Meanwhile the surface that can configure everything is the one demanding the most
expertise: hand-author provision.env, wrap it in a cidata-labeled volume, and
attach it on the right bus — where getting the bus wrong fails silently (the
cloud kernel has no AHCI driver, so a SATA seed is invisible, ds-identify finds
no datasource, and the box comes up on defaults with the config ignored).
The gap is a clean split, not a scattering
Surveying every configuration knob against every surface produces two tiers that behave differently, and the tier predicts the coverage exactly.
Tier 1 — host / .env, read at container start, authority is kg-operator
| Knob | Web | Console | operator.sh |
provision.env |
|---|---|---|---|---|
EXTERNAL_URL, TLS_DOMAIN |
❌ | ❌ | partial (recert) |
✅ |
LE_EMAIL, KG_ACME_DNS_PROVIDER, PORKBUN_* |
❌ | ❌ | partial | ✅ |
VITE_OAUTH_CLIENT_ID / _REDIRECT_URI / VITE_API_URL |
❌ | ❌ | ❌ | derived |
KG_HTTP_PORT / KG_HTTPS_PORT |
❌ | ❌ | ❌ | ❌ |
KG_COCKPIT_ALLOW_CIDRS |
❌ | ✅ | ✅ | ✅ |
| Static IP / netmask / gateway / DNS / VLAN | ❌ | ❌ | ❌ | ❌ (DHCP only) |
Tier 2 — database, live-read, authority is the API
| Knob | Web | configure.py |
|---|---|---|
| AI providers + encrypted keys | ✅ save / test / enumerate / activate | ✅ ai-provider, api-key, models |
| Embedding profiles | ✅ | ✅ embedding |
| Search similarity threshold | ✅ (ADR-508) | — |
| Admin users / roles | ✅ | ✅ admin |
| Backup / restore / scheduler | ✅ | — |
| OAuth clients | ❌ | ✅ oauth |
platform_config flags (ADR-211) |
❌ | ✅ platform-config |
The web UI is missing all of Tier 1 and two Tier-2 stragglers. Tier 2 is otherwise well covered, and the web version is materially better than the CLI — the AI Providers card saves, tests connectivity, enumerates models, and gates activation.
Why Tier 1 is absent is structural, not neglect
The API container owns no .env and holds no Docker socket. That is the
three-layer control plane working as designed (ADR-103): host (Cockpit / console),
platform (kg-operator, Docker socket + config authority), application (web +
API). A TLS panel therefore cannot be implemented inside the web UI — it can only
be proxied. Any design that closes the Tier-1 gap must either respect that boundary
or knowingly collapse it.
Two further pressures
Network is the one knob that cannot be repaired from the surface it enables.
Today the console defers network changes to Cockpit — which listens on :9090 of
the very network being fixed. An appliance whose management surface can strand
itself has no recovery path short of a hypervisor console shell. Every mature
appliance solves this the same way and for the same reason: ESXi's DCUI, pfSense's
console, TrueNAS, Home Assistant OS, and UniFi all put network configuration on the
out-of-band console and nowhere else.
Roughly a year of accumulation has produced three implementations of "make a
working platform" — install.sh (2823 lines, carrying its own duplicate
generate_secrets(), the drift behind #502), operator/lib/{headless,guided}-init.sh
plus init-secrets.sh (1805 lines), and the appliance's kg-firstboot.sh (which at
least delegates). ADR-104 Part A already sentenced the duplicate generator; issue
512 tracks it, unstarted. Adding surfaces without removing duplicates would make
the consistency problem worse, not better.
One enabling primitive already exists. web/docker-entrypoint.sh writes
window.APP_CONFIG (apiUrl, oauth.clientId, oauth.redirectUri) into
config.js at container start, and client code reads window.APP_CONFIG?.…
ahead of the build-time import.meta.env.VITE_* fallback. A hostname re-key is
therefore an .env rewrite plus a kg-web restart — no image rebuild. That
primitive landed with the PR #526 appliance OAuth fix and makes post-boot
reconfiguration feasible at all.
Design value, inherited from ADR-119: minimize novelty. The shape below is ESXi/pfSense/TrueNAS, not an invention.
Decision
A — Tier ownership is the rule for which surface owns a knob
A configuration knob belongs to Tier 1 if applying it requires writing .env
or restarting containers, and to Tier 2 if it is a database row the API reads
live. Tier determines authority:
| Tier 1 | Tier 2 | |
|---|---|---|
| Authority | kg-operator |
API |
| Applied by | .env write + targeted container restart |
database write, read live |
| Reachable from web | only by proxy (§D) | directly |
| Reachable from console | yes, natively | via the operator shell (escape hatch) |
New knobs are classified on introduction. This is the test that decides where a surface goes, replacing case-by-case judgment.
B — operator.sh verbs are the single contract; three surfaces render it
Per ADR-119 §C, the warm-reconfig verbs are idempotent operator.sh commands, with
the existing cockpit-access verb as the template. There is exactly one
implementation; three surfaces call it.
flowchart LR
P[provision.env<br/>cold · cloud-init] --> C[operator.sh verbs<br/>THE CONTRACT]
D[console DCUI<br/>interactive · out-of-band] --> C
W[web admin<br/>post-boot re-key] -->|API → operator RPC| C
C --> E[.env write]
C --> R[targeted container restart]
Each verb encapsulates the full ripple of its change, so a hostname change
cannot silently half-apply. For external URL that ripple is: EXTERNAL_URL /
TLS_DOMAIN in .env, the Traefik router, the registered OAuth redirect_uri,
the web runtime config (config.js via the entrypoint) plus a kg-web restart,
Cockpit Origins, and the ACME certificate.
Every verb must be callable non-interactively. Programmatic configuration is a first-class requirement, not a byproduct — the same verbs serve Terraform, a fleet orchestrator, and a human at a console.
Cold and warm paths share the schema and the apply logic, so they cannot drift. The carrier remains cold-provisioning/reload only (ADR-119 §A); editing it does not re-apply to a running box.
C — The console is a persistent DCUI, not a first-boot wizard
The console adopts the ESXi DCUI shape: a persistent banner showing current address, hostname, and TLS state, plus an authenticated Configure System path that is always available — not a one-shot wizard that runs once and disappears. The re-key case (box comes up on DHCP, later earns a DNS name) is as common as first boot and must not require a reinstall or a shell.
First boot remains presence-driven and non-interactive (ADR-119 §B): the DCUI does not pause or prompt during provisioning, it simply is there afterward.
Console scope is reachability and trust only — the closed set that must be true before the web console is reachable and trustworthy:
| In scope | Out of scope (web UI owns it) |
|---|---|
| Network: DHCP/static, IP, netmask, gateway, DNS, VLAN | Reasoning provider + API keys |
| Hostname / external URL | Embedding profiles, search thresholds |
| TLS mode, ACME challenge, DNS-01 credentials | Users, roles, ontologies, vocabulary |
| Cockpit access CIDRs | Backups, jobs, workers |
| Display of admin credentials + first-run URL |
The line is not a matter of taste. It falls exactly where "can I reach and trust the web console" falls. Duplicating the AI provider card into a TUI would be strictly worse than the panel that already tests connectivity and enumerates models, and would mean typing a secret at a VM console.
Network configuration is console-authoritative. The web UI and Cockpit may display network state; neither may be the only way to change it, because both depend on the network being changed.
D — The web reaches Tier 1 by proxying to the operator
kg-operator exposes a small authenticated endpoint on a local Unix socket. The
API validates Tier-1 requests and forwards them; the operator applies them via the
§B verbs. The API never writes .env and never touches the Docker socket — the
ADR-103 privilege boundary stays intact, and the internet-facing container gains no
host authority.
The shared secret is minted per instance by init-secrets.sh, never baked. Routes
are admin-RBAC gated and live in a new module rather than growing admin.py
(already 1215 lines).
The web panel must handle the self-restart wrinkle: changing the hostname rewrites
the OAuth and API URLs that the entrypoint bakes into config.js, so kg-web
restarts underneath the operator. The UI warns, then reconnects at the new URL.
E — Build the contract first; delete the duplicates once it proves out
Sequencing is deliberate. The new contract lands and is validated end-to-end on a
real VM (bare boot → DCUI configure → web re-key → operator.sh upgrade) before
anything is removed, so a working fallback exists while the new path is unproven.
Removal is then in scope and expected:
| Target | Why |
|---|---|
install.sh's duplicate generate_secrets() |
ADR-104 Part A / #512; the #502 drift |
configure.py admin, ai-provider, embedding, api-key, models |
fully superseded by the web UI |
configure.py oauth, platform-config |
after their web surfaces land |
| Console menu 4's "use Cockpit for network" deferral | replaced by §C |
The operator shell, host login, and status remain as escape hatches.
Consequences
Positive
- The intended experience becomes literally true. Import, power on, configure reachability at the console, finish in the web UI.
- One implementation of each knob. A new Tier-1 setting is added once and appears in all three surfaces; cold and warm paths cannot diverge.
- The re-key case stops requiring a shell. DHCP-IP → real DNS name is a supported operation from two surfaces.
- The privilege boundary is reinforced rather than eroded — closing the Tier-1 gap explicitly does not hand the API host authority.
- Programmatic configuration is preserved and improved — the verbs are the API
that
provision.env, Terraform, and a fleet orchestrator all target. - Nothing here is novel. ESXi/pfSense/TrueNAS shape throughout, per ADR-119's stated value.
Negative
- A new internal service seam (operator RPC) to build, authenticate, and maintain — the cost of not collapsing the privilege boundary.
- Network configuration in a shell TUI is fiddly to write and to test; netplan rendering plus VLANs is real work with real failure modes.
- A hostname change restarts the web container out from under the operator, which is an inherently awkward UX no matter how it is presented.
- The consolidation in §E touches the provisioning path, the highest-blast-radius code in the repo, and must not be attempted before §E's validation gate.
Neutral
- ADR-119 §C's follow-on is discharged by §B/§C here; ADR-119 stays the authority on delivery (carrier, first-boot branching), this ADR on post-boot surfaces.
- Advances #512 (Part A consolidation) without depending on ADR-104 Part B; the claim protocol remains separately unbuilt and orthogonal to these surfaces.
- Applies platform-wide, not only to the appliance — a dev instance or a bare-metal
install.shdeployment gains the same web Tier-1 panel.
Alternatives Considered
-
Give the API a bind-mounted
.envand Docker socket. Simplest to build: the API writes config and restarts containers directly, no new seam. Rejected because it hands the internet-facing container host-level config authority, collapsing the three-layer control plane that ADR-103 established — a large security regression bought for a modest implementation saving. -
Web admin read-only for Tier 1; console remains the sole authority. Zero new privilege seam and honest about the boundary. Rejected because the post-boot re-key case is exactly the one to be fixed; requiring console access for a routine DNS-name change is the current pain, restated.
-
A one-shot first-boot wizard. Prompt through configuration once at first power-on, then hand off to the menu. Rejected because re-key is as common as first boot; ESXi, pfSense, and TrueNAS all keep the configuration path permanently reachable rather than consuming it.
-
Put everything in the console TUI, including the reasoning key. Makes the box fully functional without ever opening the web UI. Rejected as overbuild: it duplicates a superior web panel, degrades secret entry to a VM console, and blurs the reachability/trust boundary that makes the scope decidable in §C.
-
Extend ADR-119 rather than write a new ADR. ADR-119 is still Draft, so amendment was open. Rejected because the scope here is platform-wide (the web panel applies to any deployment) whereas ADR-119 is appliance-delivery-specific, and because the config-plane ownership rule in §A is a general principle that would be buried inside an appliance ADR.