Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.cue directly and carry that as a local diff against main. There is no documented “drop a small file, get an overlay” mechanism, so changes accumulate as untracked checkout-local state or get lost during git pull.
  • No host-user awareness. The default operator account in system.cue is hardcoded as principal=operator / displayName="operator". The host user typing make run sees a generic identity, and adding their real SSH key requires manual conversion of the .pub file 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_ROOT defaults 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_path validates CAPOS_CUE against $(GIT_COMMON_DIR)/../.capos-tools/cue/<version>/bin/cue; it must follow the new default.
  • tools/check-generated-adventure-content.sh recomputes the same path in shell and is invoked by make generated-code-check. If the Makefile exports CAPOS_CUE to 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:

  • binaries declarations common to interactive boots
  • initConfig.init and initConfig.services skeletons for the password-login + anonymous-shell flow
  • kernelParams.consoleUart / kernelParams.terminalUart / kernelParams.logLevel
  • kernelParams.seedAccounts with a single canonical host-operator entry (32-byte fixed principalId)
  • kernelParams.resourceProfiles with a single canonical operator resource profile
  • kernelParams.motd
  • kernelParams.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, requiring list.Concat from 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:

  1. The manifest.bin rule’s prerequisites must include the defaults package (cue/defaults/*.cue) and system.local.cue (when it exists). Otherwise, edits to those files leave a stale manifest.bin and make run boots the previous configuration.
  2. Tag-dependent builds (CAPOS_CUE_TAGS=user=$(USER)) must invalidate cached manifest.bin when the tag value changes. The intended pattern is a sentinel file under target/ whose contents record the tag values; the manifest rule depends on the sentinel, and the sentinel is regenerated whenever CAPOS_CUE_TAGS changes. Without this, make run after a make run-smoke (different tag) silently boots the cached operator-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.motd in the overlay with a concrete string. The default is string | *"...", so a more concrete overlay value wins under CUE unification.
  • Extra SSH keys for the host operator: append to extraAuthorizedSshKeys with principalId matching the canonical operator. Multiple keys allowed.
  • Extra non-operator principals: append to extraSeedAccounts with kind: "guest", kind: "service", or future kinds. Adding a second kind: "operator" is not supported in slice 2kernel/src/cap/mod.rs::operator_seed_account rejects 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 extraResourceProfiles for 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 consoleCredential PHC blob; bound to the host operator account by being declared at the same manifest scope (today there is no explicit principalId reference 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 carrying principalId matching the host operator’s seed account.
  • Future passkey/OIDC bindings — same pattern; the user-identity-and-policy proposal already shows ExternalIdentityBinding shaped 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

SliceScopeRisk
1 (this)Proposal + WORKPLAN pointer + index entry. No code.None.
2Makefile (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.
3Migrate 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 run integration); 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 and ExternalIdentityBinding shape 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 new cue/defaults/defaults.cue, the system.local.cue overlay surface, the CAPOS_CUE_TAGS environment variable (and the target/-side sentinel that records it), and the host $USER value 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.pub into the manifest. The system.local.cue.example shows 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-gateway in system.cue. The SSH gateway service is added when its OpenSSH transport gates close (decomposed in docs/backlog/runtime-network-shell.md). Until then, an authorized SSH key declared in system.local.cue is 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 capos header.
  • This proposal does not define a remote operator-extension delivery channel (cloud-metadata, fleet config). Those are addressed by cloud-metadata-proposal.md and stay separate.

Open Questions

  • Whether principalId should ever follow the host user. This proposal fixes principalId at 32 bytes (local-operator-principal-default) so audit history is stable even if $USER changes. 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.cue lives. This proposal places it at the repo root next to system.cue. That scopes the overlay to the same package capos CUE loads in package-mode export, keeps binary path resolution unchanged, and is gitignored cleanly. The package-less system-*.cue focused-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 wires make run. If make run-shell should also personalize, slice 3 adds it; if focused proofs should always use operator, slice 3 leaves them alone.