Proposal: System Configuration and Operator Extensibility
A small, layered CUE configuration model for the boot manifest that lets
operators extend the default boot (system.cue) without forking it,
unifies the host operator into a single principal regardless of which
authentication method they use, and moves the per-user toolchain cache
out of the repository root.
Problem
The default boot manifest (system.cue) and its focused-proof siblings
(system-spawn.cue, system-shell.cue, system-telnet.cue, the various
system-ssh-*.cue, etc.) are each self-contained CUE files with a large
shared scaffold copy-pasted across them. Three concrete pain points
follow from that.
- No clean operator extension surface. An operator who wants to add
their own SSH public key, a second principal, or a different MOTD
has to edit
system.cuedirectly and carry that as a local diff againstmain. There is no documented “drop a small file, get an overlay” mechanism, so changes accumulate as untracked checkout-local state or get lost duringgit pull. - No host-user awareness. The default operator account in
system.cueis hardcoded asprincipal=operator/displayName="operator". The host user typingmake runsees a generic identity, and adding their real SSH key requires manual conversion of the.pubfile into the manifest’s hex format. The build environment already knows the host user ($USER), the SSH key (~/.ssh/id_ed25519.pub), and the typical operator preferences; none of that information reaches the manifest. - Toolchain cache lives inside the repo.
CAPOS_TOOLS_ROOTdefaults to$(GIT_COMMON_DIR)/../.capos-tools, which resolves to a directory inside the repository root for every clone. A user with three capOS clones downloads three copies of the pinned capnp, limine, cue, mdbook, and typst toolchains. Per-version subdirectories (limine/<commit>/,cue/<version>/) already make a per-user shared cache safe; the repo-local default is the only thing forcing duplication.
Adjacent design pressure: the SSH Shell Gateway milestone needs a
plausible answer to “where does the host operator’s SSH key go?” before
its run-target/init-mandate Gate D can close, and the local-users
backlog wants the host operator’s session to be a single account with
multiple authentication bindings (password, SSH key, future passkey)
rather than parallel operator/ssh-operator/passkey-operator seeds.
Design
The proposal is four small, independent moves that compose into one operator-facing extension surface.
1. Per-user toolchain cache
CAPOS_TOOLS_ROOT defaults to $(HOME)/.capos-tools instead of
$(GIT_COMMON_DIR)/../.capos-tools. The override path stays available
(set the variable explicitly to relocate). Existing per-version
subdirectories (limine/<commit>/, cue/<version>/, etc.) keep
multiple capOS clones from colliding on a single host. The first
make after the change repopulates the new path; the old in-repo
.capos-tools/ is left in place and can be removed manually.
Slice 2 must update every consumer that derives the pinned CUE path from the old default. At minimum:
tools/mkmanifest::expected_cue_pathvalidatesCAPOS_CUEagainst$(GIT_COMMON_DIR)/../.capos-tools/cue/<version>/bin/cue; it must follow the new default.tools/check-generated-adventure-content.shrecomputes the same path in shell and is invoked bymake generated-code-check. If the Makefile exportsCAPOS_CUEto the new path but the script recomputes the old one, the generated-code gate will reject the pinned CUE binary.
Any future tool that pins on $(GIT_COMMON_DIR)/../.capos-tools
must follow the same lock-step rule — slice 2 grep-audits the tree
to catch additions.
This change is independent of the rest of the proposal — it could
ship on its own — but it is bundled because the same operator-extension
narrative covers it: per-user state belongs in $HOME, not in the
repository.
2. cue/defaults/ package, packaged-default directory, and overlay shape
A new cue/defaults/defaults.cue declares package defaults and
exports #DefaultSystem capturing the shared scaffold. The
manifest decoder reads root-level schemaVersion, binaries,
initConfig, and kernelParams (with seed accounts, resource
profiles, authorized SSH keys, MOTD, UART config, and log level all
nested under kernelParams), so #DefaultSystem mirrors that exact
shape — final fields are at the document root, with kernelParams
holding the kernel-side config tree:
binariesdeclarations common to interactive bootsinitConfig.initandinitConfig.servicesskeletons for the password-login + anonymous-shell flowkernelParams.consoleUart/kernelParams.terminalUart/kernelParams.logLevelkernelParams.seedAccountswith a single canonical host-operator entry (32-byte fixedprincipalId)kernelParams.resourceProfileswith a single canonical operator resource profilekernelParams.motdkernelParams.authorizedSshKeys(empty by default)- A documented set of appendable extension inputs (see below)
that overlays use to extend lists. CUE list unification is
element-wise conflict, not concatenation; CUE v0.16 also rejects
the legacy
[a] + [b]list-arithmetic form, requiringlist.Concatfrom the standard library.
The repo’s cue.mod/module.cue declares module: "capos.local" with
language v0.16.0. The defaults package lives at cue/defaults/
and uses package defaults (not package capos) so the root
overlay can import it without a self-import.
The packaged default manifest stays at the repo root as system.cue,
declaring package capos. The overlay companion is system.local.cue
(repo root, package capos, gitignored). Existing focused-proof
manifests (system-spawn.cue, system-shell.cue, system-telnet.cue,
the system-ssh-*.cue family, etc.) stay package-less for now: they
declare no package directive and are not unified into package capos
by CUE. They keep working under their existing single-file
cue export <path> flow.
Keeping the default manifest at the repo root preserves the current
embed_binaries contract — tools/mkmanifest resolves
binaries[].path relative to the manifest’s parent directory and
rejects .., so the manifest must live in a directory from which
existing repo-root-relative paths like init/target/... are
reachable. Moving the default into a subdirectory would force a
parallel binary-path-base change in mkmanifest; that is not worth
the additional surface for the value of co-locating the overlay.
// system.cue (repo root, packaged default)
package capos
import defaults "capos.local/cue/defaults"
_user: string | *"operator" @tag(user)
#Manifest: defaults.#DefaultSystem & {
user: _user
}
// Final manifest fields the decoder consumes are at document root.
// The decoder ignores any unused names like #Manifest.
schemaVersion: #Manifest.schemaVersion
binaries: #Manifest.binaries
initConfig: #Manifest.initConfig
kernelParams: #Manifest.kernelParams
The default MOTD value lives only in the defaults package
(motd: string | *_defaultMotd, where _defaultMotd is the
multi-line capOS welcome with chat/adventure shell hints — see
cue/defaults/defaults.cue). system.cue does not assign MOTD
itself, so a cue export .:capos without an overlay still resolves
to a complete value — two sibling string | *"..." defaults from
different files would unify to “incomplete” in CUE v0.16. An overlay
refines the field by declaring a concrete value (no *), which is
more specific than the default and wins under unification:
// system.local.cue (overlay)
package capos
#Manifest: kernelParams: motd: "Hi alice — capOS dev box."
tools/mkmanifest today invokes cue export <file> against a single
file path; CUE then loads only that file (plus its imports) and does
not unify other root files even when they share a package name. Slice
2 adds a --package <name> flag that switches mkmanifest to
cue export <dir>:<name> (where <dir> is the file’s parent and
<name> is capos). The Makefile passes --package capos only for
the default-boot recipe; focused make run-* targets keep
single-file mode and are not affected by the new packaged default.
Two Makefile changes are required for slice 2 to be safe:
- The
manifest.binrule’s prerequisites must include the defaults package (cue/defaults/*.cue) andsystem.local.cue(when it exists). Otherwise, edits to those files leave a stalemanifest.binandmake runboots the previous configuration. - Tag-dependent builds (
CAPOS_CUE_TAGS=user=$(USER)) must invalidate cachedmanifest.binwhen the tag value changes. The intended pattern is a sentinel file undertarget/whose contents record the tag values; the manifest rule depends on the sentinel, and the sentinel is regenerated wheneverCAPOS_CUE_TAGSchanges. Without this,make runafter amake run-smoke(different tag) silently boots the cachedoperator-tagged manifest.
3. @tag(user) injection contract
The host user name is injected into the manifest at cue export
time via a CUE tag. Because the manifest’s authoritative tag site
must be in a file that cue export actually reads, the tag is
declared in the root overlay file (system.cue), not the
imported defaults package. CUE evaluates tag attributes at the file
where they are declared.
The tag site is in the packaged default manifest file (system.cue
at the repo root, shown above) — that file declares
_user @tag(user) and threads it into defaults.#DefaultSystem via
the user field. The defaults package itself does not need a
@tag because tags are evaluated where they appear in the input.
// cue/defaults/defaults.cue (excerpt)
package defaults
import "list"
// Fixed 32-byte principal ID — manifest validation rejects shorter
// or longer values. Only display strings vary by host user; the
// audit-correlatable principal stays stable.
_canonicalOperatorPrincipalId: "local-operator-principal-default"
#DefaultSystem: {
user: string | *"operator"
schemaVersion: 1
binaries: [...] // shared list
initConfig: {...} // anonymous-shell flow
extraSeedAccounts: [...#SeedAccount] | *[]
extraResourceProfiles: [...#ResourceProfile] | *[]
extraAuthorizedSshKeys: [...#AuthorizedSshKey] | *[]
kernelParams: {
motd: string | *"capOS default boot. Type 'login' or 'setup'."
consoleUart: {...}
terminalUart: {...}
logLevel: string | *"debug"
seedAccounts: list.Concat([[{
name: user
displayName: user
principalId: _canonicalOperatorPrincipalId
kind: "operator"
// ...
}], extraSeedAccounts])
resourceProfiles: list.Concat([[{
name: "default-operator-profile"
// ...
}], extraResourceProfiles])
authorizedSshKeys: extraAuthorizedSshKeys
}
}
tools/mkmanifest today invokes cue export <path> from Rust and
does not pass --inject / -t flags. Slice 2 adds a tag
pass-through: either a new mkmanifest --tag user=alice CLI option
that mkmanifest forwards to the underlying cue export, or — simpler
— mkmanifest reads CAPOS_CUE_TAGS from its environment and forwards
each key=value pair as --inject key=value. The Makefile sets
CAPOS_CUE_TAGS=user=$(USER) for make run only; make run-smoke
and CI-shaped targets leave it unset, so they continue to see
principal=operator / display=operator and existing audit-log
assertions are preserved.
@tag is the standard CUE pattern for build-time string injection
and is preferred over preprocessing the file with sed or generating
a wrapper file. It generalizes: future tags can carry hostname,
locale, timezone, or other build-environment-derived values without
adding more mechanisms.
4. system.local.cue overlay hook
The overlay file is system.local.cue at the repo root, declaring
package capos. It is gitignored explicitly. CUE in package mode
ignores files whose names start with ., so a leading-period
variant would not be loaded; the chosen filename has no leading dot.
In package mode (slice-2 mkmanifest invocation
cue export .:capos), CUE unifies every non-hidden *.cue file in
the directory that declares package capos — today that is just
system.cue; once the operator adds system.local.cue, both files
are unified automatically with no imperative include. The
package-less system-*.cue focused-proof manifests are not picked
up because they declare no package directive.
A checked-in system.local.cue.example (repo root, package capos)
documents the supported extension shapes with worked examples. The
operator copies it to system.local.cue to activate.
Appendable extension inputs
CUE list unification is element-wise conflict, not concatenation, so
an overlay cannot extend the defaults’ seedAccounts or
authorizedSshKeys by re-assigning the same field. The defaults
package therefore exposes named extension lists that it concatenates
into the final manifest fields:
See the defaults excerpt above for the appendable inputs
(extraSeedAccounts, extraResourceProfiles,
extraAuthorizedSshKeys) and how they are concatenated via
list.Concat — the form [a] + [b] is rejected by CUE v0.16.
The overlay populates the extra* fields on #Manifest (which is
the named definition produced by the packaged-default file), never
the final lists:
// system.local.cue (repo root, gitignored, copied from .example)
package capos
#Manifest: extraAuthorizedSshKeys: [{
keyId: "host-laptop-ed25519-2026-04"
principalId: "local-operator-principal-default"
algorithm: "ssh-ed25519"
publicKey: "hex:..." // see how-to doc
fingerprintSha256: "..."
allowedShellProfiles: ["operator"]
source: "manifest"
comment: "host laptop"
}]
The principal id stays the fixed 32-byte canonical value — the
overlay does not derive a per-user principal id. Display strings
change with @tag(user); the audit-correlatable identity does not.
Worked extension scope (slice-2 supported)
The overlay ships supporting these operator extensions in slice 2:
- MOTD: re-declare
#Manifest.kernelParams.motdin the overlay with a concrete string. The default isstring | *"...", so a more concrete overlay value wins under CUE unification. - Extra SSH keys for the host operator: append to
extraAuthorizedSshKeyswithprincipalIdmatching the canonical operator. Multiple keys allowed. - Extra non-operator principals: append to
extraSeedAccountswithkind: "guest",kind: "service", or future kinds. Adding a secondkind: "operator"is not supported in slice 2 —kernel/src/cap/mod.rs::operator_seed_accountrejects manifests with more than one operator seed for password login. Multi-operator support is a separate change in the user-identity-and-policy track. - Extra resource profiles: append to
extraResourceProfilesfor custom quota templates referenced by extra accounts.
The proposal does not generate the SSH hex/fingerprint conversion
in the Makefile — that lives in docs/configuration.md as a
short ssh-keygen -lf ~/.ssh/id_ed25519.pub + xxd/base64 -d
pipe. Keeping this manual avoids importing arbitrary host SSH keys
into the boot manifest by default.
5. Single-account-multi-auth invariant
The host operator is one account with potentially many authentication bindings:
- Password verifier — current
consoleCredentialPHC blob; bound to the host operator account by being declared at the same manifest scope (today there is no explicitprincipalIdreference in the credential record, but the kernel resolves the operator principal from the seed account at session-mint time). - SSH public keys — multiple records in
authorizedSshKeys, each carryingprincipalIdmatching the host operator’s seed account. - Future passkey/OIDC bindings — same pattern; the
user-identity-and-policy proposal already shows
ExternalIdentityBindingshaped this way.
The kernel’s operator_session_metadata already pulls the principal
from the manifest seed account when present (see
kernel/src/cap/session_manager.rs OperatorSeedAccount); the
hardcoded b"operator" fallback fires only when no seed account is
declared. Once system.cue declares the host-operator seed account
explicitly, both password login and SSH public-key login mint a
session for the same principal. The AuthorityBroker.shellBundle
path is unchanged — it already routes through the AccountStore by
principal id (after the SSH AccountStore-bound auth slice landed at
commit 33100f4).
Importantly: this is not a kernel change. It is a manifest-shape
choice that makes the existing kernel resolution path the canonical
one. The bootstrap fallback (no seed account → hardcoded operator
principal) stays in place for focused proofs that intentionally test
the no-account-store path.
Migration Plan
| Slice | Scope | Risk |
|---|---|---|
| 1 (this) | Proposal + WORKPLAN pointer + index entry. No code. | None. |
| 2 | Makefile (CAPOS_TOOLS_ROOT default, CAPOS_CUE_TAGS sentinel-file dependency for make run, manifest-rule prerequisites for the defaults package and system.local.cue); cue/defaults/defaults.cue; system.cue rewrite (stays at repo root, becomes package capos); system.local.cue.example (committed at repo root); tools/mkmanifest package-mode flag (--package capos switching to cue export <dir>:capos), tag pass-through, and updated expected_cue_path for the new tools-root default; docs/configuration.md; CLAUDE.md project-layout note. | Medium — touches Makefile, mkmanifest CLI surface, the default boot manifest, and adds a new package directory. Smoke harness assertions on principal=operator must keep passing because slice 2 leaves the default tag at operator. |
| 3 | Migrate system-spawn.cue, system-shell.cue, system-telnet.cue, system-ssh-*.cue, system-local-users.cue, and the rest of the variants onto the defaults package. One commit per variant or grouped by audit area. | Low per variant once slice 2 is in. Coordinated with parallel agents to avoid worktree collisions. |
Slice 2 is intentionally minimal so that any breakage shows up on the
default make run / make run-smoke path immediately, rather than
hidden behind a fan-out of converted variants.
Cross-References
docs/architecture/manifest-startup.md— describes the current single-file CUE evaluation and mkmanifest flow that this proposal extends with a package + overlay shape.docs/backlog/local-users-management.md— Gate 1 manifest-seeded accounts; this proposal shapes the default manifest’s seed account to match the single-account-multi-auth invariant the backlog calls for.docs/backlog/run-targets-and-init-policy.md— Gate D (default-make runintegration); this proposal makes Gate D closure for the SSH milestone tractable by giving the default manifest a clean place to absorb optional services and authorized keys.docs/proposals/ssh-shell-proposal.md— consumes the host-user authorized-key surface in a future slice once OpenSSH transport gates land.docs/proposals/user-identity-and-policy-proposal.md— defines the principal/account/session model andExternalIdentityBindingshape that this proposal’s single-account-multi-auth invariant relies on. Multi-operator support is tracked there.docs/proposals/system-info-proposal.md— adjacent precedent for “rename + structural cleanup + worked Phase 2”; this proposal adopts the same status-header and cross-reference shape.docs/trusted-build-inputs.md— needs entries for the newcue/defaults/defaults.cue, thesystem.local.cueoverlay surface, theCAPOS_CUE_TAGSenvironment variable (and thetarget/-side sentinel that records it), and the host$USERvalue injected via@tag(user)— all become trusted boot-manifest inputs once slice 2 lands.
Non-Goals
- This proposal does not auto-ingest
~/.ssh/id_ed25519.pubinto the manifest. Thesystem.local.cue.exampleshows how the operator ingests their key explicitly. Auto-ingestion is a separate decision that has security implications (which keys count? how is the hex/fingerprint conversion validated?) and should not be bundled with the configuration-shape change. - This proposal does not auto-start
ssh-gatewayinsystem.cue. The SSH gateway service is added when its OpenSSH transport gates close (decomposed indocs/backlog/runtime-network-shell.md). Until then, an authorized SSH key declared insystem.local.cueis plumbing-only. - This proposal does not introduce a CUE-level imperative
“include if file exists” mechanism. CUE’s same-package unification
already provides the overlay behavior; the operator’s only action
is to drop a file with the right
package caposheader. - This proposal does not define a remote operator-extension
delivery channel (cloud-metadata, fleet config). Those are
addressed by
cloud-metadata-proposal.mdand stay separate.
Open Questions
- Whether
principalIdshould ever follow the host user. This proposal fixesprincipalIdat 32 bytes (local-operator-principal-default) so audit history is stable even if$USERchanges. A future per-user-derived principal id would need a deterministic, validated 32-byte derivation and a rollover plan; that is out of scope here. - Where
system.local.cuelives. This proposal places it at the repo root next tosystem.cue. That scopes the overlay to the samepackage caposCUE loads in package-mode export, keeps binary path resolution unchanged, and is gitignored cleanly. The package-lesssystem-*.cuefocused-proof manifests are not picked up by package-mode export because they declare no package directive — so this is settled. - Whether to migrate focused proofs to the defaults package.
Slice 3 assumes yes because it removes copy-paste, but some
variants (
system-measure.cue,system-spawn.cue) intentionally diverge from the default boot for their proof shape. May leave those untouched. - Tag injection for
run-shell/run-terminal/ focused interactive proofs. Slice 2 only wiresmake run. Ifmake run-shellshould also personalize, slice 3 adds it; if focused proofs should always useoperator, slice 3 leaves them alone.