myxce/.kiro/specs/admin-config-tool/design.md
Rongsong Shen e4fd9e1d3f feat(admin-config-tool): wire xceadmin into meson + nix build
Reopens the 2026-07-21 design decision to keep xceadmin outside meson.
Standard onboarding consistency (README 3.3 `nix build .#xcecore` and
3.4 `meson compile` working for xceadmin like every other component)
outweighs the earlier concern about meson's single-artifact model not
fitting a multi-file pip install.

- src/xceadmin/meson.build: custom_target() wrapping install+test,
  stamp-file output, build_always_stale (matches this repo's existing
  clean-rebuild-every-invocation convention).
- src/xceadmin/meson-build.sh: out-of-tree pinned install + pytest;
  stages a repo-mirror/ symlink tree so tests' fixed-relative-path
  assumptions (src/configs/, src/schema/, ha/data/) still resolve from
  a copied location, without touching their deliberate fail-loud
  behavior.
- src/meson.build: subdir('xceadmin') under component != 'xcecli'.
- src/tools/xce-build.sh: xceadmin case now delegates to
  `meson compile -C "$BUILD_DIR" xceadmin` instead of its own
  pip/venv implementation, like every other component.

Verified both build paths: meson compile (165/165 passed) and
nix build --option sandbox false .#xcecore_debug (128 passed / 37
skipped — cert-dependent tests correctly skip under nix's
git-tracked-only hermetic source). AppImage bundling (README 3.5)
is explicitly deferred to future work, not part of this change.
2026-07-22 10:46:45 +00:00

53 KiB
Raw Blame History

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).

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

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)

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 <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
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
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
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
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
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
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
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 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).