# Proposal: System Configuration and Operator Extensibility

Current operator-facing design authority now lives in
[`docs/configuration.md`](../configuration.md). Manifest/startup authority lives
in [`docs/architecture/manifest-startup.md`](../architecture/manifest-startup.md).
This proposal is retained as the archival rationale and implementation history.

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`, 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 `name="operator"` /
  `displayName="operator"`. The host user typing `make run` sees a
  generic login 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.
- **Superseded cache default:** the original implementation used
  `$(GIT_COMMON_DIR)/../.capos-tools`, which created one pinned-tool cache per
  clone. The implemented default is now `$(HOME)/.capos-tools` through
  `CAPOS_TOOLS_ROOT`, with per-version subdirectories such as
  `limine/<commit>/` and `cue/<version>/`.

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
  `$(CAPOS_TOOLS_ROOT)/cue/<version>/bin/cue`.
- `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 repo-selected helpers must follow
`CAPOS_TOOLS_ROOT` in lock step with the Makefile.

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`. Every repo-root
`system-*.cue` manifest now declares its own CUE package and imports
the defaults package, except `system-paperclips.cue` and
`system-adventure.cue` (demo-owned, package-less but still importing
defaults) and `system-measure.cue` (owned by the measure-mode-repair
plan and intentionally not migrated yet). See the Slice-3 inventory
table below for the full mapping.

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_USER=$(USER)` and optional
   `CAPOS_CUE_DISPLAY_NAME=...`) 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 the CUE tag environment
   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:     userDisplayName
            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 environment tags and forwards each value as
`--inject key=value`. The Makefile sets `CAPOS_CUE_USER=$(USER)` for
`make run` only; mkmanifest derives `displayName` from that same
account's passwd comment unless `CAPOS_CUE_DISPLAY_NAME` is explicitly
set. `make run-smoke` and CI-shaped targets leave them unset, so
untagged `system.cue` continues to see `account=operator` /
`display=operator`; focused smoke manifests may pin demo-specific
account fixtures independently.

`@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 (slices 2 and 3)

The overlay ships supporting these operator extensions:

- **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.
- **Console password verifier**: override
  `#Manifest.kernelParams.consolePasswordVerifierPhc` (Argon2id PHC
  string) so the development verifier shipped by the defaults package
  is replaced for any non-research deployment.
- **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.
- **Extra boot binaries**: append to `extraBinaries` with `name` and
  repo-relative `path`. The defaults package concatenates the list
  onto its `_baseBinaries` so `mkmanifest` embeds the operator binary
  into `manifest.bin` alongside the default service set.
- **Extra init-launched services**: append to `extraServices` with
  `name`, `binary` (resolved against `binaries`), `restart`, and the
  cap graph the service should receive at spawn. The defaults
  concatenate operator extras after `_baseServices`, so init starts
  the operator service after the default chat server, remote-session
  gateway, and shell.

Task 4 closeout (`2026-05-03 18:51 EEST`): `system.local.cue.example`
covers every extension above. The plan calls for `make run` as the
verification target, but `make run` is interactive, so verification
ran `make manifest` (default `MANIFEST_SOURCE=system.cue`, package
mode `--package capos`) with the example copied to
`system.local.cue`. The package-mode rebuild emitted the operator
MOTD into `manifest.bin` (3 services, 12 binaries → 2551416 bytes,
log `target/manifest-refreshed-example.log`); rebuilding the same
target with the overlay absent produced 2553224 bytes, confirming
the operator MOTD overrode the defaults' default value. `make
run-smoke` was *not* a useful overlay verification because that
target builds `manifest-smoke.bin` from `system-smoke.cue` in
single-file mode (no `--package` flag, no sibling-file unification);
md5 of `manifest-smoke.bin` was identical with and without the
overlay file present.

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 compatibility 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 + task ledger 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. Closed at commit `a50f610d` (`2026-05-03 21:54 UTC`): Task 2 migrated the owned set (see the Slice-3 inventory table below), Task 3 tightened the manifest decoder to reject unknown root fields with regression tests at commit `f3d89757` (see the Slice-3 Task-3 closeout below), Task 4 refreshed `system.local.cue.example` and `docs/configuration.md` to cover every defaults-package extension hook, and Task 5 stamped this status header, the task ledger System Configuration ad-hoc bullet, and the `docs/changelog.md` entry. 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-3 manifest inventory

The table below records the migration state of every repo-root
`system-*.cue` manifest at the slice-3 Task-2 closeout. "Imports
defaults" means the file declares a CUE package and pulls in
`capos.local/cue/defaults`. "Migration shape" distinguishes between
manifests that unify the full `defaults.#DefaultSystem` scaffold (and
inherit MOTD, seed accounts, resource profiles, the base service graph,
etc.) and focused-proof manifests that intentionally reference the
defaults package only as a constant lookup for `schemaVersion`,
`logLevel`, and UART configuration. Both shapes are valid migration
targets — focused proofs need a narrow cap graph and cannot inherit the
default service tree.

| Manifest | Package | Imports defaults | Migration shape | Driven by |
| --- | --- | --- | --- | --- |
| `system.cue` | `capos` | yes | full scaffold | `make run`, `make remote-session-ui` |
| `system-spawn.cue` | `spawn` | yes | constant lookup | `make run-spawn` |
| `system-shell.cue` | `shell` | yes | constant lookup | `make run-shell` |
| `system-terminal.cue` | `terminal` | yes | constant lookup | `make run-terminal` |
| `system-credential.cue` | `credential` | yes | constant lookup | `make run-credential` |
| `system-login.cue` | `login` | yes | full scaffold | `make run-login` |
| `system-login-setup.cue` | `loginsetup` | yes | full scaffold | `make run-login-setup` |
| `system-local-users.cue` | `localusers` | yes | constant lookup | `make run-local-users` |
| `system-revocable-read.cue` | `revocableread` | yes | constant lookup | `make run-revocable-read` |
| `system-memoryobject-shared.cue` | `memoryobjectshared` | yes | constant lookup | `make run-memoryobject-shared` |
| `system-restricted-shell-launcher.cue` | `restrictedshelllauncher` | yes | constant lookup | `make run-restricted-shell-launcher` |
| `system-chat.cue` | `chat` | yes | full scaffold | `make run-chat` |
| `system-smoke.cue` | `smoke` | yes | full scaffold | `make run-smoke`, `make run-diagnostics`, `make run-iommu-acpi`, `make run-acpi-pcie`, `make run-net`, `make run-uefi`, `make run-pci-nvme`, `make run-ringtap-failing-call` |
| `system-session-context.cue` | `sessioncontext` | yes | constant lookup | `make run-session-context` |
| `system-ipc-zerocopy.cue` | `ipczerocopy` | yes | constant lookup | `make run-ipc-zerocopy` |
| `system-service-object-routing.cue` | `serviceobjectrouting` | yes | constant lookup | `make run-service-object-routing` |
| `system-tcp-listen-authority.cue` | `tcplistenauthority` | yes | constant lookup | `make run-tcp-listen-authority` |
| `system-capnp-chat-interop.cue` | `capnpchatinterop` | yes | constant lookup | `make run-capnp-chat-interop-vm` |
| `system-thread-scale.cue` | `threadscale` | yes | constant lookup | `make run-thread-scale` |
| `system-smp-process-scale.cue` | `smpprocessscale` | yes | constant lookup | `make run-smp-process-scale` |
| `system-remote-session-capset-interop.cue` | `remotesessioncapsetinterop` | yes | constant lookup | `make run-remote-session-capset-interop-vm` |
| `system-remote-session-adventure-interop.cue` | `remotesessionadventureinterop` | yes | constant lookup | `make run-remote-session-adventure-interop-vm` |
| `system-ssh-host-key.cue` | `sshhostkey` | yes | constant lookup | `make run-ssh-host-key` |
| `system-ssh-authorized-key.cue` | `sshauthorizedkey` | yes | constant lookup | `make run-ssh-authorized-key` |
| `system-ssh-public-key-session.cue` | `sshpublickeysession` | yes | constant lookup | `make run-ssh-public-key-session` |
| `system-ssh-public-key-auth.cue` | `sshpublickeyauth` | yes | constant lookup | `make run-ssh-public-key-auth` |
| `system-ssh-feature-policy.cue` | `sshfeaturepolicy` | yes | constant lookup | `make run-ssh-feature-policy` |
| `system-paperclips.cue` | none | yes | demo-owned scaffold use | `make run-paperclips` |
| `system-adventure.cue` | none | yes | demo-owned scaffold use | `make run-adventure` |
| `system-measure.cue` | none | no | unmigrated; owned by measure-mode-repair plan | `make run-measure` |

`system-paperclips.cue` and `system-adventure.cue` are demo-owned and
not part of the slice-3 conflict surface. They already pull
`#DefaultSystem` for the operator account fixture but stay package-less
because their `make run-*` targets predate the package-mode flag.
Migrating them onto a `package paperclips` / `package adventure` shape
is a follow-up coordinated through the demo plans rather than slice 3.
`system-measure.cue` waits for `docs/backlog/scheduler-evolution.md` to
close, then can be migrated in its own batch.

All manifests added after the Slice-3 closeout (C payload manifests,
DDF grant manifests, hardware-audit variants, POSIX adapter smokes, WASI
smokes, wasm-host, thread-fairness variants, scheduler/scheduling-context,
limit proofs, and remote-session variants) follow the same convention: each
declares its own CUE package and imports `capos.local/cue/defaults`. The
table above is a Slice-3 migration snapshot; it is not exhaustive of all
current repo-root `system-*.cue` files.

### Slice-3 Task 3 closeout

Closed `2026-05-03 20:22 UTC` at commit `f3d89757`. The
`SystemManifest` CUE decoder
(`capos-config/src/manifest.rs`) now validates the document root
against an explicit allow-list and returns
`Error::UnknownField { path, field, expected }` for any other top-level
name. The accepted set lives in the decoder
(`SYSTEM_MANIFEST_ROOT_FIELDS`) and is `schemaVersion`, `binaries`,
`initConfig`, `kernelParams` — adding a future field is a deliberate
edit to that list. Two host-side tests in
`capos-config/src/manifest.rs` (`system_manifest_rejects_unknown_root_field`
and `system_manifest_accepts_only_known_root_fields`) pin both the
rejection path and the positive case so a regression is caught by
`cargo test-config` before any QEMU run. The Cap'n Proto schema for
`SystemManifest` is closed by construction, so the strictness check
only needs to live at the CUE/JSON boundary; capnp decode paths
remain unchanged. The slice-3 inventory above guarantees that every
owned focused-proof manifest already projects only those four fields
at the document root, so the rule does not break any migrated
manifest. `docs/configuration.md` records the operator-facing
behavior of the new error.

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/service-architecture-proposal.md`](service-architecture-proposal.md)
  — primary consumer of the layered manifest: `initConfig.services`
  and the `extraServices` extension hook described above feed the
  authority-at-spawn service graph. The defaults package owns the
  base service tree (chat server, remote-session gateway, shell);
  overlays append operator-owned services without forking it.
- [`docs/proposals/userspace-binaries-proposal.md`](userspace-binaries-proposal.md)
  — defines the binary set the layered manifest embeds. The
  `binaries`/`extraBinaries` shape covers native Rust capos-rt
  binaries, libcapos C-substrate binaries, the POSIX adapter
  binaries, and the wasm-host binary uniformly; per-language
  payload conventions (for example, the wasm-host's stable
  `wasi-payload` manifest name) are documented there.
- [`docs/proposals/posix-adapter-proposal.md`](posix-adapter-proposal.md)
  — POSIX adapter smokes (`make run-posix-dns-smoke`,
  `make run-posix-pipe-smoke`, `make run-posix-stdio-smoke`) are
  driven by focused `system-posix-*.cue` manifests that live in the
  same package-mode/overlay regime as the rest of the migrated
  manifest set. Operator-installable POSIX-ported services attach
  through `extraBinaries`/`extraServices` and inherit the same
  authority-at-spawn grants the default service tree uses.
- [`docs/proposals/wasi-host-adapter-proposal.md`](wasi-host-adapter-proposal.md)
  — per-instance text grants (`initConfig.init.wasiArgs`,
  `initConfig.init.wasiEnv`) are CUE-authored manifest fields that
  flow through this proposal's package-mode evaluation; the
  manifest-decoder strictness invariant closed in Slice 3 Task 3
  is the same gate that catches mistyped WASI argv/env field names
  before a payload boots.
- [`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. The Slice-3 inventory table
  above records the migration state for every repo-root
  `system-*.cue` manifest. The intentionally divergent
  `system-measure.cue` is left for a follow-up batch keyed off the
  measure-mode-repair plan.
- **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.
