myxce/.kiro/specs/admin-config-tool/design.md
Rongsong Shen 6193fa7ea1
test(admin-config-tool): update standby-redirect E2E for bug-1700/1701
test_live_config_push_against_standby_returns_standby_rejection_not_redirect
locked in the OLD controller behavior (a standby flatly rejects
OP_upgradeConfig with no redirect fields). That gap is now closed:
myreq_handler's gate-rejection path calls fill_redirect_reply() on
every -XCE_ERR_STANDBY rejection (bug-1700), and handle_ctrlhello() now
actually populates primary_host/primary_port from the static --peers
list (bug-1701) -- a standby pushing config now redirects to the
primary and the push succeeds. Renamed to
test_live_config_push_against_standby_redirects_and_succeeds and
rewritten to assert the new behavior.

_assert_primary_committed() changed from a single-shot log read to a
poll loop (up to 5s) for the "config activated from committed
marker..." line -- accounts for the documented async gap between the
majority-ack-gated client reply and the primary's own local WAL-apply-
pump activation, a real race the old single-shot read could hit.

meson-build.sh: wire src/tools and each build profile's controller
subdir into the isolated repo-mirror the E2E tests run against, and
export XCEADMIN_BIN explicitly, so these live tests actually find real
binaries instead of always skipping.

Verified: xceadmin suite 288/288 (incl. both tests in this file);
controller suite 1220/1222 (only the pre-existing bug-1561 flake).
Updated admin-config-tool design.md and roadmap.md to mark the
dispatch-gate and redirect-wiring gaps RESOLVED, matching this test's
new assertions and the live evidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QxaSzHRB7pmYFp4cVnwkA
2026-08-29 12:10:28 +00:00

547 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Technical Design: admin-config-tool
## Overview
`xceadmin` is a standalone Python 3.10+ CLI that compiles operator-authored YAML into the `ClusterConfig` Cap'n Proto binary the XCE controller expects (`config build`), and delivers that binary to a running controller cluster over the same TLS+Cap'n Proto wire protocol controllers already speak to each other (`config push`). It is the first Python component in an otherwise C/Go codebase and the first external (non-daemon) client of the controller's raw wire protocol.
**Users**: XCE cluster operators managing cluster topology, queues, resources, users, limits, autoscaler providers, and cluster-identity settings through git-managed YAML rather than hand-crafted binaries or manual `scp`.
**Impact**: Today `cluster.bin` is a hand-built, provenance-free artifact and `OP_upgradeConfig` has no external caller. This spec adds the missing YAML→binary compiler and the missing binary→controller uploader, closing both gaps client-side.
### Goals
- Deterministic YAML → `cluster.bin` compilation covering all 13 `ClusterConfig` fields (9.1–9.3, 1.1–1.12).
- TLS+capnp delivery of `cluster.bin` to a controller, following redirects to the primary (2.1–2.7, 3.1–3.4).
- Fail-fast validation with actionable errors before anything reaches a running cluster (4.1–4.5, 7.1–7.6).
- A reusable `xcecommon` package so a future Python `xcecli` does not re-derive capnp/wire-protocol code (5.1–5.4).
- Toolchain integration: `xce-build`, `xce-deploy-ha`, nix dev shell (8.1–8.6).
### Non-Goals
- Controller-side replication, snapshot, restart, rollback, version history (owned by `config-upgrade-snapshot`).
- Fixing the controller-side `OP_upgradeConfig` dispatch defect discovered during this design's discovery phase (see Risks) — that is a controller bug, not an xceadmin gap.
- `config diff` — descoped to roadmap follow-up during requirements review (2026-07-21).
- Peer/candidate membership management, per-instance perf tuning (`port`, hash-table sizes, `xstrings`) — physically absent from `ClusterConfig` after `clusterconfig-schema-split`.
## Boundary Commitments
### This Spec Owns
- The `xceadmin` CLI (`config build`, `config push`, `config push --dry-run`).
- The `xcecommon` package: capnp schema loading, YAML→capnp field mapping, JSON Schema + business-rule validation, TLS+framing wire client. Both schema types (`controller.capnp` and the 7 JSON schemas) are packaged as bundled copies (`package_data`), synced from `src/schema/` and `src/configs/schema/` at build time — not read from an operator-supplied path at runtime (2026-07-21 revision; superseded Requirement 1.6, see Requirement 5.5).
- Build/toolchain integration: `src/xceadmin/meson.build` (`meson compile`/`nix build .#xcecore[_debug]`), `xce-build.sh` delegating dispatch, nix dev-shell Python packages, `xce-deploy-ha` `--build-config` flag — see File Structure Plan Design Review correction / Amendment.
- Test fixtures and golden files under `src/xceadmin/tests/`.
### Out of Boundary
- ~~**Controller-side `OP_upgradeConfig` dispatch**~~ — **RESOLVED 2026-07-25 (bug-1468).** `cluster.c`'s `myreq_handler` `OPCode_upgradeConfig` case had its primary/standby guard polarity inverted by an unrelated commit (`fd93997f`, 2026-05-31: `if (ha_is_primary(...)) reject` instead of the correct `if (!ha_is_primary(...)) reject`), which rejected the one role — primary — that `ha_dispatch_gate()` ever lets reach this switch case. Restored to the correct polarity; the primary now reaches `do_upgrade_config_with_bytes()` via the network as designed. Live-verified: `test_e2e_live_push.py::test_live_config_push_against_real_single_node_controller`, `test_e2e_live_ha_push.py::test_live_config_push_against_primary_reaches_success_path`.
- ~~**Redirect wiring for `OP_upgradeConfig`**~~ — **RESOLVED 2026-08-26 (bug-1700/1701).** `cluster.c`'s generic `ha_dispatch_gate()` rejection path now calls `fill_redirect_reply()` whenever the gate rejects with `-XCE_ERR_STANDBY` (`cluster.c:411-437`) — a fix at the gate itself, covering every opcode that hits that rejection, not an `OPCode_upgradeConfig`-specific patch. `RequestReply.redirectHost`/`redirectPort` are now populated when a standby rejects `config push`, and xceadmin's already-generic redirect-following (Allowed Dependencies) transparently retries against the primary. Live-verified: `test_e2e_live_ha_push.py::test_live_config_push_against_standby_redirects_and_succeeds`.
- **`ProbeConfigTag` server-side handling** — `xce_probe_config_tag()` (`config_repl.c:1829`) has no server-side dispatch case anywhere in `cluster.c` and no production caller; it is dormant plumbing. xceadmin's dry-run client implements the wire contract but has nothing live to call.
- Entity-bearing snapshots, config rollback, version history, config activation status (owned by `config-upgrade-snapshot`, blocked upstream on `entity-bearing-snapshots`).
- TLS mutual authentication / hostname verification — none exists anywhere in the codebase (`SSL_VERIFY_NONE` is set explicitly for the controller's own outbound client context, `myapp.c:109`; no `X509_check_host` call exists). xceadmin matches this existing posture; it neither introduces nor removes an authentication guarantee.
### Allowed Dependencies
- `src/schema/controller.capnp` and `src/schema/c.capnp` — schema source of truth, read-only. `xceadmin` packages a synced *copy* (see File Structure Plan / `xcecommon.schema`) rather than referencing this path directly at runtime — `src/schema/` itself stays owned by the C build, not by this spec.
- `src/configs/*.yaml` — the 7 example configs already authored (this session) as operator-facing documentation; xceadmin's test fixtures are separate, purpose-built golden inputs under `src/xceadmin/tests/fixtures/`, not these files. `src/configs/schema/*.schema.json` is different in kind from the YAML examples: it's the source of truth `xcecommon` packages a synced copy of (2026-07-21 revision — see `xcecommon.validation`), the same relationship `src/schema/controller.capnp` already has.
- `xce.crt`/`xce.key` under `ha/data/` (or `--cert-dir`) — same cert bundle the controller cluster already uses.
- Nix `python3Packages`: `pycapnp`, `pyyaml`, `jsonschema`, `click`, `pytest`, `ruff` (net-new additions to `src/nixSupport/xce.nix`).
- `src/xceadmin/meson.build`'s `custom_target()` as the build-integration point, delegated to by `src/tools/xce-build.sh`'s case-statement dispatch pattern — see Design Review correction / Amendment below.
### Revalidation Triggers
- `controller.capnp` `ClusterConfig` field renumbering/addition/removal (already happened once this session via `clusterconfig-schema-split`) — invalidates the field-mapping table in Data Models.
- ~~A fix to the `cluster.c`/`ha.c` dispatch-gate contradiction~~ — **TRIGGERED 2026-07-25 (bug-1468).** `config push` E2E now runs against a real cluster (`test_e2e_live_push.py`, `test_e2e_live_ha_push.py`), superseding the "mock controller only" framing in Testing Strategy.
- ~~`fill_redirect_reply()` extended to `OPCode_upgradeConfig`~~ — **TRIGGERED 2026-08-26 (bug-1700/1701).** Closed via a broader, gate-level fix (every opcode hitting the `-XCE_ERR_STANDBY` rejection gets a redirect, not an `OPCode_upgradeConfig`-specific patch) — see Boundary Commitments. Live auto-primary-discovery testing for config push now exists: `test_e2e_live_ha_push.py::test_live_config_push_against_standby_redirects_and_succeeds`.
- A server-side handler added for `OPCode_probeConfigTag` — unlocks `config push --dry-run` E2E testing.
## Architecture
### Existing Architecture Analysis
XCE's daemon-to-daemon wire protocol is hand-rolled: `iomgr`/`vtaskmgr` (epoll loop + thread-pool dispatch) drive a raw-`SSL`-over-TCP channel framed as `uint64_t` size prefix (native/host byte order, **not** network byte order — confirmed at `netio.c:190-199`, `request.c:863-938`, `config_repl.c:1925-1968`, no byte-swap anywhere in the path) followed by that many bytes of a Cap'n Proto `Request`/`Reply` message. There is no Cap'n Proto RPC layer (no vat network) — every exchange is one framed message out, one framed message back. `src/lib/request.c` already contains a minimal one-shot raw-SSL helper pattern (`xce_probe_config_tag`-adjacent code, `request.c:863-938`) that this design mirrors from the Python side.
No precedent exists for a non-daemon client of this protocol: `src/tools/` has no socket/capnp client, `xce_test` is a WAL/snapshot file-inspection CLI with no network code, and `xcecli` (Go) speaks HTTP/JSON to `apiserver`, never this raw protocol. `xceadmin` is the first.
### Architecture Pattern & Boundary Map
**Selected pattern**: layered CLI — thin `click` entry point over a reusable library (`xcecommon`), matching the project's existing `controllers → services → store/backend` inward-dependency convention (`structure.md`) adapted for a one-shot tool (no persistent store; "runtime" is a single TLS exchange or a single file write).
```mermaid
graph TB
CLI[xceadmin cli] --> Build[xceadmin build]
CLI --> Push[xceadmin push]
Build --> Validation[xcecommon validation]
Build --> Mapping[xcecommon mapping]
Push --> Wire[xcecommon wire]
Push --> Discovery[xceadmin discovery]
Discovery --> Wire
Mapping --> Schema[xcecommon schema]
Wire --> Schema
Validation --> YamlFiles[YAML configs]
Mapping --> ClusterBin[cluster bin file]
Wire --> Controller[XCE controller]
```
**Architecture Integration**:
- Selected pattern: layered CLI over a shared library — the library (`xcecommon`) has zero CLI/`click` dependency so a future Python `xcecli` (brief.md Future Roadmap) can import it directly.
- Domain boundaries: `xcecommon` owns protocol/schema/mapping concerns; `xceadmin` owns orchestration and CLI ergonomics (exit codes, stderr/stdout conventions, flag parsing). Neither owns controller-side behavior.
- Existing patterns preserved: framing format, TLS cert bundle reuse, opcode/union shapes are consumed exactly as the C side defines them — no protocol reinterpretation.
- New components rationale: `xcecommon` did not exist (first Python in the repo); every module below is net-new.
- Steering compliance: Cap'n Proto for wire format (`structure.md` — "Cap'n Proto for wire formats"), component lives under `src/<component>/` (`structure.md` — "Organization Philosophy"), fail-fast input validation (`security.md` — "Validate at the API boundary").
**Dependency Direction**: `xcecommon.schema` → `{xcecommon.mapping, xcecommon.wire}` → `xcecommon.validation` (independent, no inbound dependency from schema/mapping/wire) → `{xceadmin.build, xceadmin.discovery}` → `xceadmin.push` → `xceadmin.cli`. Each layer imports only from layers to its left; `xcecommon.*` never imports anything from `xceadmin.*` — this is what keeps `xcecommon` importable by a future Python `xcecli` without dragging in `click` or CLI-specific exit-code handling.
**Parallel-safe implementation boundaries**: within `xcecommon`, `validation.py` has no dependency on `schema.py`/`mapping.py`/`wire.py` and can be implemented independently of them; `mapping.py` and `wire.py` both depend only on `schema.py`, not on each other, so they can be implemented in parallel once `schema.py` is stable. Within `xceadmin`, `build.py` (depends on `xcecommon.{validation,mapping}`) and `discovery.py`+`push.py` (depend on `xcecommon.wire`) touch disjoint `xcecommon` modules and can be implemented in parallel once the underlying `xcecommon` layer is stable.
### Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|-------|------------------|------------------|-------|
| CLI | `click` (nixpkgs `python3Packages.click`) | `config build`/`config push`/`config push --dry-run` subcommand groups | Grouped-command idiom: `@click.group()` → `@cli.group()` → `@config.command()` |
| Capnp | `pycapnp` ~2.2.x (nixpkgs) | Load `controller.capnp`, build/encode `ClusterConfig`, decode `Request`/`Reply` | Dynamic-reflection (`DynamicStruct`) API; `$C.mapname(...)` annotations are declared-and-inert, not a parse hazard (verified against `capnp.pyx` source) |
| YAML | `pyyaml` (nixpkgs) | Parse the 7 config files | — |
| Schema validation | `jsonschema` (nixpkgs), Draft 2020-12 | Validate each YAML against `xceadmin`'s bundled JSON schemas (`package_data`, same mechanism as `xcecommon.schema`'s `controller.capnp`) | Source of truth is `src/configs/schema/*.schema.json`; synced into the package at build time, never read from `configs_dir` at runtime (2026-07-21 revision — see `xcecommon.validation`) |
| Transport | Python stdlib `ssl` + `socket` | TLS-wrapped TCP to controller | `ssl.SSLContext` with `check_hostname=False`, `verify_mode=ssl.CERT_NONE` — matches the controller's own outbound posture (`myapp.c:109`), not a weakening |
| Test framework | `pytest` (nixpkgs) | Golden-file + mock-controller tests | Plain byte-equality fixture comparison (see Testing Strategy — `pytest-regressions` considered, not adopted, to avoid an unverified new nix package) |
| Packaging | `pyproject.toml` + `pip install -e .` (dev) / `pip install .` (release) | Editable vs. pinned install | Runs inside nix dev shell so `pycapnp`'s C-extension build links nix-provided `capnproto` |
## File Structure Plan
### Directory Structure
```
src/xceadmin/
├── pyproject.toml # package metadata; entry point xceadmin = xceadmin.cli:main
│ # Wired into meson via src/xceadmin/meson.build (2026-07-22
│ # amendment — see Design Review correction below)
├── src/
│ ├── xcecommon/ # shared library — no click/CLI dependency, reusable by future Python xcecli
│ │ ├── __init__.py
│ │ ├── errors.py # XceAdminError base + all subclasses (single CLI dispatch surface)
│ │ ├── schema_files/ # packaged copies, synced from src/ at build (not hand-edited)
│ │ │ ├── controller.capnp # <- src/schema/controller.capnp
│ │ │ ├── c.capnp # <- src/schema/c.capnp
│ │ │ └── json/ # <- src/configs/schema/*.schema.json (7 files)
│ │ ├── schema.py # capnp.load(controller.capnp) wrapper + typed accessors; resolves schema_files/ via importlib.resources
│ │ ├── mapping.py # declarative YAML-section -> capnp-field table + 2 mapper shapes (list, scalar)
│ │ ├── validation.py # JSON Schema validation (bundled schema_files/json/), reserved-name / dup / undefined-ref checks
│ │ └── wire.py # TLS connect, 8-byte native-order framing, generic send_request()
│ └── xceadmin/
│ ├── __init__.py
│ ├── cli.py # click group: config build / config push / config push --dry-run
│ ├── build.py # config build orchestration: scan dir -> validate -> map -> serialize -> sha256
│ ├── push.py # config push orchestration: connect -> discover primary -> send -> display
│ └── discovery.py # generic redirect-following (Req 3) — opcode-agnostic
└── tests/
├── fixtures/
│ ├── minimal/configs/{hosts,queues}.yaml + expected.bin # no schema/ — validated against xceadmin's bundled schemas
│ ├── full/configs/{hosts,queues,resources,users,limits,xce,scaler}.yaml + expected.bin
│ └── invalid/configs/... # one fixture dir per validation-error case (4.1, 4.2, 4.3, 4.4, 4.5)
├── test_schema_bundling.py # default_schema_path()/default_schema_dir() resolve under the active install (5.5) — only meaningful under `xce-build xceadmin release`, see Testing Strategy
├── test_mapping.py # pure unit tests: map_list_section/map_scalar_section against in-memory dicts, no file I/O (1.1, 1.7, 1.8, 1.9, 1.10, 1.11, 1.12, 5.3, 9.1, 9.2, 9.3) — gap closed during task generation, was in Testing Strategy's "Unit — mapping" bullet but missing from this file list
├── test_config_build.py # golden-file build tests (1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11, 1.12, 9.1, 9.2, 9.3)
├── test_schema_validation.py # per-error-case validation tests (4.1, 4.2, 4.3, 4.4, 4.5)
├── test_wire_framing.py # framing pack/unpack against known byte sequences (2.1)
└── test_config_push_mock.py # mock TLS server, opcode + redirect + error-reply cases (2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 3.1, 3.2, 3.3, 3.4, 7.1, 7.2, 7.3)
```
`xcecommon` and `xceadmin` are separate top-level packages under one `src/xceadmin/src/` tree, installed together via one `pyproject.toml` for now (two console-ready packages, one distributable unit) — splitting into two distributions is deferred until a second consumer (the future Python `xcecli`) actually exists (Simplification: no speculative packaging split).
### Modified Files
- `src/tools/xce-build.sh` — new `xceadmin)` case: sync `src/schema/{controller,c}.capnp` → `src/xceadmin/src/xcecommon/schema_files/` and `src/configs/schema/*.schema.json` → `src/xceadmin/src/xcecommon/schema_files/json/` (`cp`, source of truth stays `src/schema/` and `src/configs/schema/` respectively — 2026-07-21 revision extends the sync to JSON schemas, Requirement 5.5), then `pip install -e .`/`pip install .` by profile, then `pytest` (8.1, 8.2). The sync step runs on every build so a `pip install .` (non-editable, Req 8.2) always packages the current schemas as `package_data` — without it, `load_schema()`/`xcecommon.validation`'s default resolution would silently package stale or missing copies. **`pytest` runs after whichever install just happened, not before** — this ordering is what makes the `release` profile's test run (8.2) the only one that actually exercises `default_schema_path()`/`default_schema_dir()` resolving under a real non-editable `site-packages` layout; the default (dev/editable) profile's test run (8.1) never would, since editable installs keep resolving into the checked-out `schema_files/` copy either way. **Design Review correction (original, 2026-07-21)**: `xceadmin` is dispatched directly by this case statement and is deliberately **not** wired into `src/meson.build`/`src/meson.options`/a `src/xceadmin/meson.build` — `xcecli`'s `custom_target()` pattern fits a single static-binary output; `pip install -e .` mutates a venv/site-packages tree with no single trackable output artifact, so forcing it through meson's build graph would be a dead end. `xceadmin` sits orthogonal to the meson component option entirely, matching what `brief.md`'s harness sketch already specified before this design pass over-engineered a meson integration.
**Amendment (2026-07-22, supersedes the correction above)**: the user reopened this decision — standard-onboarding consistency (README 3.3 `nix build .#xcecore[_debug]` and 3.4 `meson compile` working for xceadmin exactly like every other component) outweighs the multi-file-output concern. `src/xceadmin/meson.build` wraps the install+test cycle in a `custom_target()` whose output is a stamp file (`xceadmin-build.stamp`), not a tracked multi-file tree — the real work (out-of-tree `pip install .` into a build-dir-local venv, then `pytest`) runs in `src/xceadmin/meson-build.sh`, invoked with `build_always_stale: true` so it reruns unconditionally rather than relying on ninja's file-mtime cache (matching this project's existing "clean rebuild every invocation" convention, same as every `xce-build.sh` component's `rm -rf "$BUILD_DIR"`). `src/meson.build` gains `subdir('xceadmin')` inside the `component != 'xcecli'` block, so it builds under `component=all`/`component=xcecore` (where `xcecore`'s `mesonFlags` already sets `-Dcomponent=xcecore`) but not under a pure `xcecli` build. `src/tools/xce-build.sh`'s `xceadmin)` case now just delegates (`setup_build; meson compile -C "$BUILD_DIR" xceadmin`) instead of carrying its own separate pip/venv implementation — the one meaningful behavior change is that the editable-vs-pinned distinction (Req 8.1 vs. 8.2) collapses: every `xce-build.sh xceadmin` invocation now runs the pinned/non-editable install path, a strictly stronger test (it exercises real `package_data` resolution on every run, not just under a `release` profile). A purity constraint the original design's rejection didn't have to solve: several tests reach sibling directories via a fixed relative path (`src/configs/`, `src/schema/`, `ha/data/xce.crt`/`.key`) that assume the checked-out repo's exact directory nesting; `meson-build.sh` resolves this by staging a `repo-mirror/` tree in the build dir with symlinks recreating that exact layout, rather than touching the tests' deliberate fail-loud (not skip-on-missing-layout) assertions. Verified both paths end-to-end: `meson compile -C xce.build xceadmin` (165/165 passed, real `ha/data/xce.crt` symlinked from the live checkout) and `nix build --option sandbox false .#xcecore_debug` (128 passed / 37 skipped — the skips are the existing `skipif` on `ha/data/xce.crt` not present, correctly firing since nix's git-tracked-only flake source has no committed cert files; 0 failed). AppImage bundling (README 3.5) is explicitly deferred — out of scope for this amendment, tracked as roadmap future work, not implemented here.
- `src/nixSupport/xce.nix` — add `python3`, `python3Packages.{pycapnp,pyyaml,jsonschema,click,pytest,ruff}` to `nativeBuildInputs` (8.6).
- `.claude/skills/xce-build/SKILL.md` — document the `xceadmin` component and its pip-based (non-meson-profile) build path (8.1, 8.2).
- `src/tools/deploy-ha.sh` — **(gap closed during task generation — the actual runtime behavior for 8.3-8.5 belongs here, not just in documentation)** add a `--build-config` flag; before starting the cluster, if `--build-config` is passed or `ha/data/cluster.bin` is missing, run `xceadmin config build configs/ -o ha/data/cluster.bin` (8.3, 8.4); if `cluster.bin` already exists and `--build-config` is not passed, use it as-is, unchanged (8.5).
- `.claude/skills/xce-deploy-ha/SKILL.md` — document the `--build-config` flag and the new precondition step added to `deploy-ha.sh` above (8.3, 8.4, 8.5).
- `.claude/settings.local.json` / `.claude/settings.json` — permission allowlist and ruff format/check hooks for `*.py` (full JSON already drafted in `brief.md` Harness Enhancement section; applied verbatim here, not re-derived).
## System Flows
### Config Build
```mermaid
flowchart TD
Start[xceadmin config build dir] --> ScanFiles[scan 7 known yaml filenames]
ScanFiles --> PerFile{file present}
PerFile -- no --> EmptySection[treat section as empty]
PerFile -- yes --> Validate[validate against bundled jsonschema]
Validate -- fail --> ErrValidation[report file field constraint exit nonzero]
Validate -- pass --> BusinessRules[reserved name dup check undefined ref warn]
BusinessRules --> Map[map to ClusterConfig fields]
EmptySection --> Map
Map --> Encode[pycapnp encode]
Encode --> Write[write cluster bin and sha256 to stderr]
```
Gating conditions: a missing individual YAML file is not an error (1.5, empty section) — there is no "missing schema" error mode at all (1.6, superseded 2026-07-21: schemas are bundled with `xceadmin`, not read from `configs_dir`). Schema validation failures are hard errors (4.1); business-rule warnings (undefined references) do not stop the build (4.4).
### Config Push (including auto-discovery)
```mermaid
sequenceDiagram
participant Op as Operator
participant X as xceadmin push
participant C1 as Controller node1
participant C2 as Controller node primary
Op->>X: config push cluster.bin
X->>C1: TLS connect, Request OP_upgradeConfig
C1-->>X: Reply
alt redirectHost set
X->>C2: TLS connect, resend Request
C2-->>X: Reply UpgradeConfigReply
end
X->>Op: versionTag ackedCount error, exit code
```
At most 3 redirect hops (3.2); each hop is a fresh TLS connection with the same 30s timeout (3.4, 7.6). `fill_redirect_reply()` now fires for a standby's `OP_upgradeConfig` rejection too (bug-1700/1701, see Boundary Commitments) — the `alt` branch above is live-verified against a real 3-node cluster (`test_e2e_live_ha_push.py::test_live_config_push_against_standby_redirects_and_succeeds`), not just mock-controller tests.
## Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1 | Config Build | `xceadmin.build`, `xcecommon.{validation,mapping,schema}` | `build_cluster_config()`, `map_section()` | Config Build |
| 2 | Config Push | `xceadmin.push`, `xcecommon.wire` | `send_request()`, `push_config()` | Config Push |
| 3 | Auto Primary Discovery | `xceadmin.discovery` | `follow_redirects()` | Config Push |
| 4 | Validation | `xcecommon.validation` | `validate_yaml()`, `check_business_rules()` | Config Build |
| 5 | xcecommon Shared Package | `xcecommon.{schema,wire,mapping,validation}` | package-level exports, `default_schema_path()`, `default_schema_dir()` | — |
| 6 | Dry-Run Push (Nice-to-Have) | `xceadmin.push`, `xcecommon.wire` | `probe_config()` | Config Push (dry-run variant, not diagrammed — mirrors push minus commit) |
| 7 | Error Handling & Resilience | `xceadmin.cli`, `xcecommon.wire` | `click.ClickException` mapping, socket timeout handling | Config Push |
| 8 | Skill & Harness Integration | build/nix/skill files (File Structure Plan — Modified Files) | — | — |
| 9 | Deterministic Builds | `xcecommon.mapping`, `xceadmin.build` | `build_cluster_config()` (no wall-clock/host/user inputs) | Config Build |
## Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| `xcecommon.schema` | Library | Load `controller.capnp`, expose typed roots | 5.1, 5.5, 7.4 | `controller.capnp` (P0) | Service |
| `xcecommon.mapping` | Library | YAML section dict → capnp field values | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11, 1.12, 5.3, 9.1, 9.2, 9.3 | `xcecommon.schema` (P0) | Service |
| `xcecommon.validation` | Library | JSON Schema + business-rule checks | 4.1, 4.2, 4.3, 4.4, 4.5, 5.5 | `xcecommon/schema_files/json/*.schema.json` (P0, bundled) | Service |
| `xcecommon.wire` | Library | TLS connect, framing, generic request/reply | 2.1, 2.3, 2.4, 5.2, 6.1, 7.1, 7.3, 7.6 | `xce.crt`/`xce.key` (P0), `xcecommon.schema` (P0) | Service |
| `xceadmin.build` | CLI orchestration | `config build` command | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11, 1.12, 9.1, 9.2, 9.3 | `xcecommon.{validation,mapping}` (P0) | Service |
| `xceadmin.push` | CLI orchestration | `config push` / `--dry-run` | 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 6.1, 6.2, 6.3, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6 | `xcecommon.wire` (P0), `xceadmin.discovery` (P0) | Service |
| `xceadmin.discovery` | CLI orchestration | Redirect-following | 3.1, 3.2, 3.3, 3.4 | `xcecommon.wire` (P0) | Service |
| `xceadmin.cli` | CLI entry | Argument parsing, exit codes | 1.2, 1.3, 2.2, 2.4, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 8.1, 8.2 | all `xceadmin.*` (P0) | Service |
Only components introducing a new boundary get a full block below; `xceadmin.cli` is presentation-only (click wiring) and is covered by the summary row plus its Implementation Note.
### Library
#### xcecommon.schema
| Field | Detail |
|-------|--------|
| Intent | Load and cache the compiled `controller.capnp` schema; expose the `ClusterConfig`, `Request`, `Reply`, `UpgradeConfigRequest/Reply`, `ProbeConfigTagRequest/Reply` types. |
| Requirements | 5.1, 5.5, 7.4 |
**Responsibilities & Constraints**
- Single load point — `capnp.load(path, imports=[schema_dir])` called once per process; other modules import the returned module object, never re-load.
- Resolves `c.capnp`'s sibling-directory import via the `imports` argument, pointed at wherever `default_schema_path()` resolved the copy to.
- Read-only: never mutates or re-serializes the schema itself.
- **Default path resolution (Design Review correction, 2026-07-21 — a hosted-discussion follow-up caught that the first draft never specified this, which meant `pip install .` / Req 8.2 was unimplementable as designed)**: `default_schema_path()` uses `importlib.resources.files("xcecommon") / "schema_files" / "controller.capnp"` — this resolves correctly for both `pip install -e .` (Req 8.1, still points into the checked-out repo's `schema_files/` copy) and `pip install .` (Req 8.2, resolves into whatever `site-packages` copy `package_data` installed) identically, unlike a `__file__`-relative `../../../schema/` walk, which only survives editable installs. `--schema-path <path>` (CLI) / `XCEADMIN_SCHEMA_PATH` (env var) override the default for troubleshooting, per 7.4's "suggest checking the installation."
**Dependencies**
- External: `pycapnp` (P0) — schema compilation and `DynamicStruct` message API.
- External: `xcecommon/schema_files/{controller,c}.capnp` (P0) — packaged copy, synced from `src/schema/` at build time (File Structure Plan → Modified Files); failure path is 7.4.
**Contracts**: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
##### Service Interface
```python
def default_schema_path() -> str:
"""importlib.resources.files('xcecommon') / 'schema_files' / 'controller.capnp',
overridable via --schema-path / XCEADMIN_SCHEMA_PATH."""
def load_schema(schema_path: str | None = None) -> "capnp.Capnp":
"""Load controller.capnp (default_schema_path() if schema_path is None);
raise SchemaNotFoundError if the resolved path or its c.capnp import is missing."""
class SchemaNotFoundError(XceAdminError):
path: str
```
- Preconditions: `schema_path` (explicit or resolved) points at `controller.capnp`; `c.capnp` sits alongside it.
- Postconditions: returned module exposes `.ClusterConfig`, `.Request`, `.Reply`, `.UpgradeConfigRequest`, `.UpgradeConfigReply`, `.ProbeConfigTagRequest`, `.ProbeConfigTagReply`.
- Invariants: no network or filesystem writes.
#### xcecommon.mapping
| Field | Detail |
|-------|--------|
| Intent | Convert validated YAML section dicts into `ClusterConfig` capnp field values, and back (field metadata reused by any future `config diff`). |
| Requirements | 1.1, 1.7, 1.8, 1.9, 1.10, 1.11, 1.12, 5.3, 9.1, 9.2, 9.3 |
**Responsibilities & Constraints**
- One declarative table (`SECTION_MAP`) drives all 7 sections — each entry states: YAML filename, top-level YAML key, target capnp field name (`@N`), and mapper kind (`list` or `scalar`). Two mapper functions (`map_list_section`, `map_scalar_section`) implement every section — no per-file bespoke mapper (Generalization, see `research.md`).
- Two mapper shapes only: **list** sections (`resources`, `queues`, `ugroups`, `hgroups`, `hostConfigs`, `hproviders`, `limits` — YAML top key is an array, mapped to `List(Struct)`) and **scalar** section (`xce.yaml` — flat key/value, mapped to 5 top-level `ClusterConfig` scalar fields).
- Enforces 9.1, 9.2, 9.3: no timestamp/hostname/username is ever read into a mapped value; list-section ordering follows YAML source order exactly, not sorted or hashed (order stability is required for byte-identical output).
- `configGeneration` (`@12`) is never touched by this module (1.9) — capnp leaves it at its default (0) unconditionally.
- `candidates`, `port`, hash-table-size fields, `xstrings` do not exist in the schema (post `clusterconfig-schema-split`) — there is nothing for this module to exclude; 1.8/1.10 are satisfied structurally, not by runtime logic.
**Dependencies**
- Inbound: `xceadmin.build` (P0) — sole caller.
- Outbound: `xcecommon.schema` (P0) — target field types.
**Contracts**: Service [x]
##### Service Interface
```python
from typing import Literal, TypedDict
class SectionSpec(TypedDict):
yaml_file: str # e.g. "queues.yaml"
yaml_key: str # e.g. "queues"
capnp_field: str # e.g. "queues"
kind: Literal["list", "scalar"]
SECTION_MAP: list[SectionSpec] # 7 entries, one per YAML file
def map_list_section(spec: SectionSpec, items: list[dict], target: "capnp._DynamicStructBuilder") -> None:
"""Populate target.<capnp_field> as a capnp List sized to len(items)."""
def map_scalar_section(spec: SectionSpec, values: dict, target: "capnp._DynamicStructBuilder") -> None:
"""Set each scalar capnp field on target directly from values."""
```
- Preconditions: `items`/`values` already passed `xcecommon.validation`.
- Postconditions: `target` (a `ClusterConfig` builder) has exactly the fields for the given section set; no other field is touched.
- Invariants: idempotent given the same input dict — calling twice with identical input produces byte-identical serialized output (9.1).
**Full field-mapping table** (Data Models has the authoritative field-by-field version — see Data Contracts & Integration).
#### xcecommon.validation
| Field | Detail |
|-------|--------|
| Intent | JSON Schema validation plus business rules (reserved names, duplicates, undefined references) not expressible in JSON Schema alone. |
| Requirements | 4.1, 4.2, 4.3, 4.4, 4.5, 5.5 |
**Responsibilities & Constraints**
- **Bundled schemas (2026-07-21 revision, Requirement 5.5, supersedes Requirement 1.6)**: `default_schema_dir()` uses `importlib.resources.files("xcecommon") / "schema_files" / "json"` — same `importlib.resources` mechanism as `xcecommon.schema.default_schema_path()`, correct for both `pip install -e .` and `pip install .`. `configs_dir` is never consulted for a `schema/` subdirectory; there is no "schema not found in configs_dir" error path anymore. No CLI override exists for this one (unlike `--schema-path` for the capnp schema) — Simplification: no requirement or use case calls for operator-swappable *validation* schemas, only for pointing at a different `controller.capnp` during cross-version troubleshooting.
- `validate_yaml(file, schema_name)` — `schema_name` is the YAML file's base name with `.yaml` stripped (e.g. `queues.yaml` → `"queues"` → `default_schema_dir() / "queues.schema.json"`), matching `SECTION_MAP`'s `yaml_file` entries and the naming convention the 7 files under `src/configs/schema/` already follow. `jsonschema.Draft202012Validator`; on failure raises with file name, JSON-pointer field path, and violated constraint (4.1).
- `check_reserved_names(data)` — rejects `"lost_and_found"` for any queue/host name (4.2), independent of schema validity.
- `check_duplicates(all_sections)` — per-file/per-section uniqueness only; a limit name may collide with a queue name (4.3, scoped intentionally). Raises like `check_reserved_names`, not return-and-let-caller-decide — 4.3, like 4.2, is an unconditional hard-fail per requirements.md, so the two share the same raise-on-violation contract (Design Review correction: an earlier draft had `check_duplicates` return messages for the caller to judge, which was an unjustified control-flow inconsistency between two checks with identical fail-semantics).
- `check_undefined_refs(all_sections)` — cross-file warnings only (queue→host-group, limit→group/`resname`); never blocks the build (4.4). `resources.yaml` is treated as the exhaustive resource catalog — every `resname` elsewhere, including the built-in/dynamic names (`slots`, `mem`, the 8 `add_hostattrs_resvals()` attributes), must resolve against it or this check warns (4.5).
**Dependencies**
- Inbound: `xceadmin.build` (P0).
- External: `jsonschema` (P0), `xcecommon/schema_files/json/*.schema.json` (P0, bundled — synced from `src/configs/schema/` at build time, File Structure Plan → Modified Files).
**Contracts**: Service [x]
##### Service Interface
```python
class ValidationError(XceAdminError):
file: str
json_pointer: str
constraint: str
class ReservedNameError(XceAdminError): ...
class DuplicateEntryError(XceAdminError): ...
def default_schema_dir() -> str:
"""importlib.resources.files('xcecommon') / 'schema_files' / 'json'."""
def validate_yaml(file_path: str, schema_name: str) -> dict: ... # raises ValidationError; schema_name resolved under default_schema_dir()
def check_reserved_names(sections: dict) -> None: ... # raises ReservedNameError
def check_duplicates(sections: dict) -> None: ... # raises DuplicateEntryError
def check_undefined_refs(sections: dict) -> list[str]: # returns warning messages, never raises — the one intentionally non-fatal check (4.4)
```
All raising exceptions here subclass `XceAdminError` (defined in `xcecommon.errors`, see `xceadmin.cli` Implementation Note) so the CLI boundary has one dispatch surface instead of catching unrelated types per module.
#### xcecommon.wire
| Field | Detail |
|-------|--------|
| Intent | TLS connection, the 8-byte native-order framing, and a generic single-request/single-reply exchange — opcode-agnostic so it also serves `ProbeConfigTag`'s direct-struct variant. |
| Requirements | 2.1, 2.3, 2.4, 5.2, 6.1, 7.1, 7.3, 7.6 |
**Responsibilities & Constraints**
- `connect(host, port, cert_dir, timeout=30)` — `socket.create_connection` wrapped by `ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)` with `check_hostname=False`, `verify_mode=ssl.CERT_NONE`, loading `xce.crt`/`xce.key` from `cert_dir` (2.3, 2.4, 7.1, 7.6). This mirrors the controller's own outbound TLS posture exactly (`myapp.c:103-109`) — confirmed no CN/peer-cert check exists anywhere in the codebase to diverge from.
- `send_frame(sock, payload: bytes)` / `recv_frame(sock) -> bytes` — `struct.pack("=Q", len(payload))` / `struct.unpack("=Q", ...)`, **native byte order**, matching `netio.c`'s raw-memory-copy framing exactly (verified: no `htole64`/`htobe64` anywhere in the C framing path).
- `send_request(sock, request: "Request") -> "Reply"` — generic: encode via `.to_bytes()`, frame, send, receive frame, decode via `Reply.from_bytes()`. Used for `OP_upgradeConfig`; the same primitives (not the same function, since `ProbeConfigTagRequest`/`Reply` are direct structs, not `Request`/`Reply`-union-wrapped) serve `config push --dry-run`'s `ProbeConfigTag` client.
- Never passes an `SSLSocket` or its `fileno()` into a pycapnp read/write call — decode/encode happens in-memory (`from_bytes`/`to_bytes`) against plaintext buffers assembled by `recv_frame`/`send_frame`, so TLS is never bypassed (verified gotcha from discovery: pycapnp's `.write()` calls `fileno()` and writes to the raw fd, which would skip encryption entirely on a wrapped socket).
**Dependencies**
- Inbound: `xceadmin.push`, `xceadmin.discovery` (P0).
- External: Python stdlib `ssl`, `socket`, `struct` (P0); `xcecommon.schema` (P0).
**Contracts**: Service [x]
##### Service Interface
```python
import socket, ssl
def connect(host: str, port: int, cert_dir: str, timeout_s: float = 30.0) -> ssl.SSLSocket: ...
def send_frame(sock: ssl.SSLSocket, payload: bytes) -> None: ...
def recv_frame(sock: ssl.SSLSocket) -> bytes: ...
def send_request(sock: ssl.SSLSocket, request_bytes: bytes) -> bytes:
"""Frame+send request_bytes, receive+unframe one reply. Raises
ConnectionTimeoutError / ConnectionRefusedError on failure."""
class ConnectionTimeoutError(XceAdminError): host: str; port: int
class ReplyDecodeError(XceAdminError): reason: str # corrupt/truncated capnp bytes, 7.3
```
- Preconditions: `cert_dir` contains `xce.crt`+`xce.key`.
- Postconditions: exactly one request sent, exactly one reply returned, per call — no retry, no connection pooling (one-shot CLI, not a daemon; Simplification).
- Invariants: socket closed on every exit path, including exceptions and `SIGINT` (7.5).
### CLI Orchestration
#### xceadmin.build
| Field | Detail |
|-------|--------|
| Intent | `config build <dir>` command: scan → validate → map → serialize → write → print SHA-256. |
| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.9, 8.3, 8.4 |
**Responsibilities & Constraints**
- Scans for the 7 known filenames only (`SECTION_MAP` keys) — not an open-ended directory walk (1.1).
- `-o <path>` / `-o -` (stdout) output targets (1.2, 1.3); SHA-256 always to stderr, regardless of `-o` target (1.4).
- No `schema/` subdirectory check — schemas are bundled (1.6, superseded); missing individual YAML file → empty section, continue (1.5).
- Unrecognized-but-schema-valid fields → warning, not an error (1.7).
**Dependencies**
- Outbound: `xcecommon.validation` (P0), `xcecommon.mapping` (P0), `xcecommon.schema` (P0, transitively).
**Contracts**: Service [x]
##### Service Interface
```python
def build_cluster_config(configs_dir: str) -> bytes:
"""Full pipeline: validate all present sections, map to ClusterConfig,
serialize. Raises ValidationError / ReservedNameError / DuplicateEntryError
on hard failures (all subclass XceAdminError)."""
def write_output(data: bytes, output_path: str) -> str:
"""Writes data to output_path ('-' => stdout). Returns hex SHA-256."""
```
#### xceadmin.push
| Field | Detail |
|-------|--------|
| Intent | `config push <file>` (commit) and `config push --dry-run <file>` (probe): connect, discover primary, send, display result. Two distinct functions, not one boolean-flagged function — the two wire messages (`Request{upgradeConfig}` vs. direct-struct `ProbeConfigTagRequest`) and their replies (`UpgradeConfigReply{versionTag, ackedCount, error}` vs. `ProbeConfigTagReply{configTag}`) share no fields, so a shared return type would misrepresent one of the two (Design Review correction — an earlier draft's single `push_config(..., dry_run: bool) -> PushResult` implied `acked_count` exists on a dry-run probe reply, which it does not). |
| Requirements | 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 6.1, 6.2, 6.3, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6 |
**Responsibilities & Constraints**
- Builds the `Request{opcode: upgradeConfig, upgradeconfigrequestmsg: {sha256, bytes}}` message exactly per `controller.capnp:628-654,768-771` (2.1).
- Delegates connection + redirect-following to `xceadmin.discovery` before sending (3.1, 3.2, 3.3, 3.4).
- `--dry-run` sends `ProbeConfigTagRequest` instead (direct struct, no `Request` wrapper — see `xcecommon.wire`), reports acceptance without committing (6.1, 6.2, 6.3). Per Boundary Commitments, this path has no live server counterpart today; it is implemented against the documented contract and exercised only by the mock-controller test suite until the controller gains a handler.
- Maps every failure mode to a specific message + non-zero exit per 7.1, 7.2, 7.3, 7.5, 7.6 (connection failure, non-zero `Reply.status`, corrupt binary, SIGINT, timeout).
**Dependencies**
- Outbound: `xcecommon.wire` (P0), `xceadmin.discovery` (P0).
**Contracts**: Service [x]
##### Service Interface
```python
class PushResult:
version_tag: int
acked_count: int
error: str
class DryRunResult:
current_generation: int # from ProbeConfigTagReply.configTag
would_be_generation: int # current_generation + 1, computed client-side for display only (6.2)
accepted: bool
reason: str # populated when accepted is False (6.3)
def push_config(cluster_bin_path: str, controller: str, cert_dir: str) -> PushResult: ...
def probe_config(cluster_bin_path: str, controller: str, cert_dir: str) -> DryRunResult: ...
```
- Postconditions (`push_config`): exit 0 iff `acked_count > 0` and `error == ""` (2.6); otherwise exit non-zero with `error` displayed (2.7).
- Postconditions (`probe_config`): exit 0 and `"OK: controller will accept this config (generation N→M)"` iff `accepted` (6.2); otherwise exit non-zero with `reason` displayed (6.3).
#### xceadmin.discovery
| Field | Detail |
|-------|--------|
| Intent | Generic, opcode-agnostic redirect-following against `RequestReply.redirectHost`/`redirectPort`. |
| Requirements | 3.1, 3.2, 3.3, 3.4 |
**Responsibilities & Constraints**
- Inspects any `Reply` for non-empty `redirectHost`/`redirectPort`; reconnects and resends up to 3 hops (3.1, 3.2), then errors.
- Displays the redirect target when a standby redirects (3.3).
- 30s "no primary elected" timeout is a property of each connection attempt, not the overall discovery loop (3.4, delegated to `xcecommon.wire.connect`).
- Implemented generically against the `RequestReply` schema shape (not `upgradeConfig`-specific) so it was correct for `OPCode_join`-style redirects from the start and required zero client-side changes once the controller closed the `upgradeConfig` gap (bug-1700/1701, 2026-08-26) — validated in practice, not just in principle (Simplification — no opcode-specific branching was ever needed).
**Dependencies**
- Outbound: `xcecommon.wire` (P0).
**Contracts**: Service [x]
##### Service Interface
```python
def follow_redirects(initial_host: str, initial_port: int, cert_dir: str, send_fn) -> tuple[str, int, bytes]:
"""send_fn(host, port) -> reply_bytes. Returns (final_host, final_port, reply_bytes)
after following <=3 redirects. Raises TooManyRedirectsError / NoPrimaryError."""
class TooManyRedirectsError(XceAdminError): hops: int
class NoPrimaryError(XceAdminError): host: str; port: int
```
### xceadmin.cli — Implementation Note
Thin `click` layer: `@click.group() cli()` → `@cli.group() config()` → `@config.command() build(...)`/`push(...)`.
**Shared exception contract** (Design Review correction — an earlier draft had 7 unrelated exception types with no unifying dispatch mechanism): `xcecommon.errors` defines one base, `class XceAdminError(Exception): message: str`, and every raising exception across `xcecommon`/`xceadmin` (`SchemaNotFoundError`, `ValidationError`, `ReservedNameError`, `DuplicateEntryError`, `ConnectionTimeoutError`, `ReplyDecodeError`, `TooManyRedirectsError`, `NoPrimaryError`) subclasses it. `xceadmin.cli` has exactly one `except XceAdminError as e: raise click.ClickException(e.message)` — a single dispatch surface, per-type message formatting happens in each exception's `__str__`/`message` construction at raise time, not in the CLI layer (7.1, 7.2, 7.3, 7.4). A `SIGINT` handler closes any open socket before re-raising as a "cancelled" message (7.5). No business logic lives here — covered by the summary row.
## Data Models
### Domain Model
No persistent entities or aggregates — `xceadmin` is a stateless, one-shot transform (YAML → bytes) and a stateless one-shot RPC client (bytes → controller → result). The only domain concept is the **`ClusterConfig` field set**, whose provenance (which YAML file owns which field) is the core invariant this design enforces.
### Data Contracts & Integration
**YAML section → `ClusterConfig` field mapping** (all 13 fields, `controller.capnp:694-708`):
| capnp field (`@N`) | YAML source | Kind | Req |
|---|---|---|---|
| `clusterName` (@0), `heartbeatInterval` (@1), `inactiveHeartbeatCount` (@2), `fetchJobInterval` (@3), `netioTimeout` (@4) | `xce.yaml` (flat keys `cluster_name`, `heartbeat_interval`, `inactive_heartbeat_count`, `fetch_job_interval`, `netio_timeout`) | scalar | 1.11 |
| `resources` (@5) | `resources.yaml` → `resources[]` | list | 1.1 |
| `queues` (@6) | `queues.yaml` → `queues[]` | list | 1.1 |
| `ugroups` (@7) | `users.yaml` → `usergroups[]` | list | 1.1 |
| `hgroups` (@8) | `hosts.yaml` → `hostgroups[]` | list | 1.1 |
| `hostConfigs` (@9) | `hosts.yaml` → `hosts[]` | list | 1.1 |
| `hproviders` (@10) | `scaler.yaml` → `scalers[]` | list | 1.12 |
| `limits` (@11) | `limits.yaml` → `limits[]` | list | 1.1 |
| `configGeneration` (@12) | none — controller-assigned | excluded | 1.9 |
`hosts.yaml` is the one file feeding two capnp fields (`hgroups` + `hostConfigs`) — `xcecommon.mapping`'s `SECTION_MAP` has two entries pointing at the same `yaml_file` with different `yaml_key`/`capnp_field` pairs; this is the only many-to-one case in the table and is called out explicitly here so it is not mistaken for a bug during implementation.
Per-field YAML-key-to-capnp-field-name renames (e.g., `groupname`→`GroupConfig.name`, `exclude_members`→`exmembers`) are enumerated in `research.md` (too granular for this table) and enforced by `xcecommon.mapping`'s per-section spec, not restated here.
### Logical Data Model
`cluster.bin` has no indexing/sharding concerns (single-file capnp blob, read once at controller startup/config-push time). Byte-for-byte determinism (9.1, 9.2, 9.3) is the only "consistency" property that matters, guaranteed by: (a) `SECTION_MAP` iterating YAML list order verbatim, never re-sorting; (b) no wall-clock/host/user value ever entering a mapped field; (c) capnp's own canonical single-segment encoding for a message built once and never mutated after `.to_bytes()`.
## Error Handling
### Error Strategy
Every `xcecommon`/`xceadmin` internal exception is caught once, at the `xceadmin.cli` boundary, and re-raised as `click.ClickException(message)` — giving a single place that guarantees "clear, actionable error, non-zero exit" (7.1, 7.2, 7.3, 7.4) without scattering `sys.exit()` calls through the library layer (library code raises; only the CLI layer exits).
### Error Categories and Responses
| Category | Trigger | Response | Req |
|---|---|---|---|
| Connection | TLS connect fails | `"cannot connect to <host:port>: <reason>"`, exit non-zero | 7.1 |
| Protocol | `Reply.status != 0` | status code + reply error text, exit non-zero | 7.2 |
| Decode | corrupt/truncated capnp bytes | `"invalid cluster.bin: <reason>"`, exit non-zero | 7.3 |
| Environment | schema file missing at expected path | missing path + installation hint | 7.4 |
| Interrupt | `SIGINT` during push | close TLS socket, `"cancelled"`, graceful exit | 7.5 |
| Timeout | any network op > 30s | `"timeout connecting to <host:port>"` | 7.6 |
### Monitoring
Out of scope — `xceadmin` is a one-shot CLI with stderr/exit-code as its only observability surface; no logging framework, no metrics endpoint (Simplification — a persistent log/metrics story belongs to a daemon, not a CLI invoked from a shell or a skill).
## Testing Strategy
- **Unit — schema bundling**: `default_schema_path()`/`default_schema_dir()` resolve to a real, non-empty, parseable `controller.capnp`/JSON schema set under whatever install is currently active — trivial in isolation, but only meaningful when it actually runs against a **non-editable** install; every other test in this list runs fine against an editable install even if `package_data` packaging is broken, since editable installs keep resolving into the checked-out source tree regardless. This is what `/xce-build xceadmin release` (8.2) validates that `/xce-build xceadmin` (8.1) cannot — a gap discovered post-design-approval (see `research.md`) that the schema-bundling fix itself would otherwise ship untested.
- **Unit — mapping**: `SECTION_MAP` list-section and scalar-section mappers each produce the expected capnp field values from a minimal in-memory dict (no file I/O) — one test per `kind`, plus the `hosts.yaml`-feeds-two-fields case explicitly.
- **Unit — wire framing**: `send_frame`/`recv_frame` round-trip against a fixed byte sequence, and against a loopback `socket.socketpair()` — verifies native-order packing matches `struct.pack("=Q", ...)` semantics without needing a real controller.
- **Integration — config build (golden-file)**: `minimal`/`full`/`invalid` fixture directories (File Structure Plan) — `full` now covers all 7 YAML files including the newly-added `xce.yaml`/`scaler.yaml`; byte-compares `build_cluster_config()` output against a committed `expected.bin` and its SHA-256 (1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11, 1.12, 9.1, 9.2, 9.3). Plain `assert produced == expected_bytes` fixture comparison — `pytest-regressions` was evaluated (see `research.md`) but not adopted, since this project's nix-pinned dependency list would need independent verification and the byte-equality need here is simple enough not to warrant it (Simplification).
- **Integration — validation**: one test per `invalid/` fixture case — schema violation, reserved name, duplicate name (including a limit/queue name collision, proving 4.3's intentionally-narrow scope), undefined reference (warn, not fail).
- **Integration — config push (mock controller)**: a Python TLS server speaking just enough of the wire protocol (frame in, decode `Request` or direct-struct `ProbeConfigTagRequest`, encode a canned `Reply`/`ProbeConfigTagReply`) to exercise `push_config()` (2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 3.1, 3.2, 3.3 redirect, 7.1, 7.2, 7.3) and `probe_config()` (6.1, 6.2, 6.3 — accepted and rejected probe replies) against the same mock, since both share `xcecommon.wire`'s framing/send-receive primitives. This remains valuable for dev-loop speed and `probe_config()` coverage (`ProbeConfigTag` still has no server-side handler — Out of Boundary), but it is no longer the only way to test `push_config()`: the controller-side dispatch-gate defect that once blocked live-cluster testing was fixed (bug-1468, 2026-07-25), and the standby-redirect gap that followed it was fixed too (bug-1700/1701, 2026-08-26) — see Boundary Commitments and E2E below.
- **E2E**: real-cluster `config push` round-trip, live against genuine `xce_controller` processes — `test_e2e_live_push.py` (single-node, task 6.2) and `test_e2e_live_ha_push.py` (3-node HA, task 6.3): primary success path (positive `ackedCount`, real standby ACKs), and the standby-redirect success path (`test_live_config_push_against_standby_redirects_and_succeeds`). No longer gated on the controller dispatch fix or the redirect fix — both landed; `brief.md`'s "Option B" is realized.
## Security Considerations
- **Transport is encryption-only, not authenticated** — no peer-certificate validation exists anywhere in XCE's controller-to-controller TLS today (`SSL_VERIFY_NONE`, no `X509_check_host`); `xceadmin` matches this exactly rather than silently adding (and thereby implying) a stronger guarantee the rest of the system doesn't have. An attacker with network access to the controller port can MITM either direction. This is a pre-existing, project-wide posture (`product.md` — "trust OS boundary"), not something this spec introduces or should unilaterally fix.
- **Input validation at the boundary** — every YAML file is JSON-Schema-validated before any byte of it reaches capnp encoding (4.1), consistent with `security.md`'s "validate at the API boundary, never trust external data."
- **No secrets in xceadmin** — no JWT, no RBAC, no credential storage; trust model is "same TLS cert bundle as the controller cluster," matching `xce_test`'s existing zero-config posture. Nothing new to rotate or leak.
- **Reply decoding must not crash on malformed input** — `xcecommon.wire.send_request`'s `Reply.from_bytes()` call is wrapped to convert a pycapnp decode exception into `7.3`'s "invalid" error path rather than an unhandled traceback, even though the reply source (a controller within the operator's own cluster) is semi-trusted.
## Supporting References
- Full per-field YAML-key ↔ capnp-field-name rename table, pycapnp API usage notes, and the two controller-side defect write-ups: `research.md`.
- Nix package list, harness hook JSON (ruff format/check, YAML schema guard), and settings allowlist: `brief.md` Harness Enhancement section (unchanged, referenced not restated here).