# 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** — `ha_dispatch_gate()` excludes `OPCode_upgradeConfig` from the standby allow-list (`ha.c:3926-3951`), and `cluster.c:667-679` calls `handle_upgrade_config()` only when `ha_is_primary()` is **false**. Combined, no controller role reaches `do_upgrade_config_with_bytes()` via the network today — it is exercised only by unit tests calling it directly. This is a controller defect, not an xceadmin defect; xceadmin implements the client side of the documented contract (`config_repl.h:347-349` — "handle an OP_upgradeConfig request from an admin tool") and cannot itself fix the gate. Flagged as a blocking risk below. - **Redirect wiring for `OP_upgradeConfig`** — `fill_redirect_reply()` (`join.c:27-31`) is only invoked from the `OPCode_join` path (`join.c:277`). `RequestReply.redirectHost`/`redirectPort` are never populated for an `upgradeConfig` reply today. xceadmin implements generic, opcode-agnostic redirect-following per the `RequestReply` contract (forward-compatible), but it will not fire for `config push` until the controller is extended. - **`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 — flips `config push` E2E testing from "mock controller only" to "real cluster required," per Testing Strategy. - `fill_redirect_reply()` extended to `OPCode_upgradeConfig` — unlocks live auto-primary-discovery testing for config push (currently only testable against `OPCode_join`-shaped mocks). - 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//` (`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). Because `fill_redirect_reply()` does not currently fire for `OPCode_upgradeConfig` (see Boundary Commitments), the `alt` branch above is exercised only in mock-controller tests until the controller gap is closed. ## 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 ` (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. 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 ` 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 ` / `-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 ` (commit) and `config push --dry-run ` (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 is correct today for `OPCode_join`-style redirects and automatically correct for `upgradeConfig` once the controller wires `fill_redirect_reply()` for that opcode too (Simplification — no opcode-specific branching to remove later). **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 : "`, 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: "`, 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 "` | 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 is the only realistic way to test config push today** — not merely "preferred for the dev loop" as `brief.md` originally framed it, but load-bearing: the controller-side dispatch-gate defect found during discovery (Boundary Commitments) means a real 3-node cluster currently rejects `OP_upgradeConfig` from every role, so live-cluster E2E (`brief.md`'s "Option B") is **blocked**, not merely deferred, until that controller defect is fixed (Revalidation Trigger). - **E2E (deferred)**: real-cluster `config push` round-trip via `xce-test-ha` — tracked in `brief.md`, explicitly gated on both the controller dispatch fix and `config-upgrade-snapshot` landing. ## 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).