# 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). Focused-proof manifests
migrate independently to their own packages so they can import the
defaults package without joining `package capos`. `system-spawn.cue`,
`system-shell.cue`, `system-terminal.cue`, `system-telnet.cue`,
`system-login.cue`, `system-login-setup.cue`, `system-local-users.cue`,
and `system-credential.cue` are packaged this way; remaining variants
such as the `system-ssh-*.cue` family still use the legacy package-less
single-file `cue export <path>` flow until their focused checks move
with them.

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.

```cue
// 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:

```cue
// 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
// 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. Focused-proof
manifests are not picked up because migrated variants use their own
package names and unmigrated variants remain package-less.

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:

```cue
// 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 2** —
  `kernel/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

| 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 focused-proof variants onto the defaults package. `system-spawn.cue`, `system-shell.cue`, `system-terminal.cue`, `system-telnet.cue`, `system-login.cue`, `system-login-setup.cue`, `system-local-users.cue`, and `system-credential.cue` are packaged; `system-ssh-*.cue` and the rest of the variants remain. 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. |
| 4 | Add `mkmanifest cue-to-capnp`, a general host-side conversion path for CUE-authored data messages rooted at a caller-specified Cap'n Proto struct. The tool reuses the slice-2 CUE package/tag machinery, validates both `CAPOS_CUE` and `CAPOS_CAPNP` against the pinned per-user tool cache, checks `cue version v0.16.0` and `Cap'n Proto version 1.2.0`, passes import paths through safe `Command` arguments, and writes the converted binary only after `capnp convert json:binary` succeeds. | Low for boot behavior because the existing manifest pipeline is unchanged. Medium host-tool risk because schema, CUE, and JSON are hostile inputs; the implementation delegates Cap'n Proto type rules to the pinned upstream converter and keeps filesystem/process boundaries explicit. |

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.

Slice 4 deliberately does not make `CueValue` universal. `CueValue` remains the
project-defined generic tree used inside `SystemManifest.initConfig`.
The general converter has a different contract:

```bash
mkmanifest cue-to-capnp \
  [--package capos] [--tag key=value ...] \
  [--import-path schema ...] [--no-standard-import] \
  input.cue schema/example.capnp Example output.bin
```

`input.cue` is exported as JSON, then the pinned Cap'n Proto tool validates
that JSON against `schema/example.capnp` and root struct `Example`. This covers
normal Cap'n Proto data fields, nested structs, lists, enums, unions, defaults,
and imports according to upstream `capnp convert` semantics. It does not
serialize live capOS capability table entries or meaningful Cap'n Proto
interface objects; authority still travels through capOS capability transfer
mechanics, not through JSON-authored data files.

## Cross-References

- [`docs/architecture/manifest-startup.md`](../architecture/manifest-startup.md)
  — describes the CUE evaluation, boot manifest build, and general
  `cue-to-capnp` host-tool flow that this proposal extends.
- [`docs/backlog/local-users-management.md`](../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`](../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`](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`](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`](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`](../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.
  Focused-proof manifests are not picked up by `package capos`
  export because migrated variants use separate package names and
  unmigrated variants 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 each variant
  must keep its proof shape and checks. `system-spawn.cue`,
  `system-shell.cue`, `system-terminal.cue`, `system-telnet.cue`,
  `system-login.cue`, `system-login-setup.cue`, `system-local-users.cue`,
  and `system-credential.cue` are migrated; intentionally divergent
  variants such as `system-measure.cue` may be left for a later focused
  migration.
- **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.
