Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Xianyun specification

Version: 0.1 (retroactive) Date: 2026-10-13 Scope: the Rust workspace under crates/ (xianyun and xianyun-mcp)

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Purpose and method

This is a retroactive specification. It was written from the Rust source, the Go tests the Rust port mirrors, and the existing design documents, then checked against a build of main at commit d5f74650. It describes what Xianyun is meant to do and marks where the code doesn’t yet do it.

Requirements use EARS patterns. Each functional requirement has a status:

  • Implemented: code and at least one test exist, the behaviour was seen working or the tests pass, and every SHALL under the requirement is met. An unmet SHOULD is noted next to the status.
  • Partial: some of the requirement works, or a SHALL under it isn’t met; the gap is named.
  • Gap: the requirement is intended and the code doesn’t meet it. Each gap has a GAP- identifier.

Every statement of status here comes from one of three sources: a test run (cargo test --workspace: 1,119 passed, 0 failed, 23 ignored on Linux, 2026-10-13; one of the ignored tests, compile_schema_lazy_loading, is an unimplemented! stub rather than a documented live-environment test), a measurement named in the threat model, or reading the code. The test run doesn’t prove a requirement; it proves the tests that exist pass.

Not examined

The Go implementation, the behaviour of the external tools Xianyun wraps, the macOS-only Keychain code (read, not run), and the contents of user-supplied Helm charts.

Definitions

TermMeaning
ProfileA named environment; selects cluster name, data directory, and CA
DriverThe cluster backend: auto, k3d, colima, vcluster, external, or mock
DeployerA configured unit that installs something into the cluster: helm, kustomize, or namespace
ReloaderA configured edit-mode process manager: mirror (mirrord) or skaffold
HookA command in lifecycle_hooks run before or around edit mode
Gated toolAn MCP tool that needs an elicitation answer before it runs

Known gaps

IDGapEvidence
GAP-001Closed 2026-10-14. No deployer manager was registered in production, so up and deploy couldn’t install anything.Before: deployer::registry::list_types() returned []. Fix: register_deployers() in crates/xianyun-cli/src/drivers.rs, with crates/xianyun-cli/tests/deploy_registry.rs and the e2e-deploy NixOS VM test, which installs a namespace, a Kustomize overlay, and a Helm chart on a real k3d cluster.
GAP-002edit-mode-process-supervisor always fails after preflight, and status returns an error when a Ready cluster has reloaders.CODE: editmodeprocesssupervisor.rs:362-385, status.rs:51-54.
GAP-003doctor runs 2 of the Go tool’s 5 diagnostics (cluster domain and orphaned mirror copies).CODE: diagnostics::builtin::all(); the Go diagnostics for disk space, macOS firewall, and node health aren’t ported and no PORT_NOTES entry records the omission.
GAP-004Closed 2026-10-13. Telemetry was dormant code that nothing called; it was removed rather than finished (ADR-0018).The module and its environment variables are gone.
GAP-005--no-cache and the persistent remote-fragment cache are unwired.CODE: no_caching is assigned at hooks.rs:64 and never read.
GAP-006run doesn’t prepare or clean up mirror deployment copies or check preconditions.CODE: run.rs, collect_mirror_namespaces is ported and unused.
GAP-007up doesn’t validate /etc/hosts before proceeding.CODE: up.rs:118 notes it as not ported “because hosts is a stub”; hosts::Client is now real.
GAP-008User-facing strings still say Caproni, and ca inspect prints raw Rust Debug output for its validity dates.MEASURED 2026-10-13: xianyun version prints caproni version 0.1.0; xianyun --help says “Start the Caproni cluster” and “search … for caproni.yaml”; xianyun ca inspect printed Not Before: Some(SystemTime { tv_sec: 1791871947, tv_nsec: 0 }).
GAP-009Cluster DNS-domain discovery runs a busybox:1.36 probe Job first, and waits up to 120 seconds for it. On a cluster that can’t pull the image, every up, deploy, and ca inspect pays the full wait.MEASURED 2026-10-14 in the NixOS VMs, which can’t pull: up on an already-running cluster took 122 seconds, deploy 123, deploy --repo 122. defaults() in crates/xianyun-cluster/src/clusterdomain/mod.rs lists the resolv.conf probe before the Corefile read, which needs no image. The order was ported from the Go tool.
GAP-010Closed 2026-10-14. A command run with no logging flags logged nothing.MEASURED: LoggingArgs::default() held empty strings, so the default logger failed to build and dispatch discarded the error. Fixed in crates/xianyun-executor/src/logging/cliflags.rs and crates/xianyun-cli/src/commands/mod.rs; found by the e2e-deploy scenario.

Functional requirements

FR-001: Configuration loading and compatibility

Status: Partial (the FR-001.1 stop rule isn’t met outside the home directory) Rationale: ADR-0003, ADR-0009

  • FR-001.1 When a command needs configuration, the system SHALL search from the working directory upward for xianyun.yaml, and SHALL fall back to caproni.yaml in the same directory when no xianyun.yaml exists there. The search SHALL stop at the user’s home directory. (Not met from a working directory outside it, where the search continues to the filesystem root; see TM-CFG-007.)
  • FR-001.2 Where both xianyun.yaml and caproni.yaml exist in one directory, the system SHALL use the xianyun.* file.
  • FR-001.3 The system SHALL merge xianyun.local.yaml (or caproni.local.yaml), extends: fragments, and xianyun-project.yaml (or caproni-project.yaml) from edit-mode repositories into the base configuration, appending list values for lifecycle_hooks, additional_hostnames, and dynamic_overrides.
  • FR-001.4 If a configuration fails validation against the embedded version 2 schema, then the system SHALL exit non-zero with a message naming the failing field as a JSON pointer (measured 2026-10-13: /cluster/driver: "nonsense" is not one of [...]). A cluster.network block is required.
Feature: Configuration loading

  Scenario: A Caproni project loads unmodified
    Given a directory containing only "caproni.yaml" with "version: 2", "cluster.driver: mock", and a "cluster.network" block
    When the user runs "xianyun config validate"
    Then the exit code MUST be 0

  Scenario: The primary name wins
    Given "xianyun.yaml" sets hostname "a.test" and "caproni.yaml" sets hostname "b.test"
    When the user runs "xianyun config print"
    Then the output MUST contain "a.test"
    And the output MUST NOT contain "b.test"

  Scenario: An invalid field is named
    Given a config with "cluster.driver: nonsense"
    When the user runs "xianyun config validate"
    Then the exit code MUST be non-zero
    And the message MUST name the JSON pointer "/cluster/driver"

Tests: crates/xianyun-core/src/config/caproni.rs (unit), crates/xianyun-cli/tests/config.rs, nix/tests/e2e-mock.nix.

FR-002: Variable expansion

Status: Implemented

  • FR-002.1 The system SHALL expand $VAR and ${VAR} in configuration values, and SHALL also support length (${#VAR}), indirection (${!VAR}), slices, replacement, and prefix and suffix removal. In configuration values it SHALL reject the default, required, alternate, and assignment forms (:-, :?, :+, :=) with an unsupported-expansion error.
  • FR-002.1a In a variables: definition the system SHALL additionally support ${VAR:-default}, ${VAR:?message}, and ${VAR:+alternate}.
  • FR-002.2 The system SHALL provide XIANYUN_CLUSTER_NAME, XIANYUN_PRIMARY_HOSTNAME, and XIANYUN_CLUSTER_DOMAIN, and SHALL treat the CAPRONI_ equivalents as aliases.
  • FR-002.3 The system SHALL NOT evaluate command substitution, backticks, or arithmetic expansion; those sequences SHALL be preserved as written.
  • FR-002.5 The system SHALL treat a backslash before a dollar sign (\$HOME) as the literal text.
  • FR-002.4 If a variable is unset and has no default or error operator, then the system SHALL fail validation for fields that reject unresolved variables (cluster.driver, k3s.disable, repository ref).
Feature: Variable expansion

  Scenario: A predefined alias expands
    Given a config value "${CAPRONI_CLUSTER_NAME}"
    When the config is loaded with profile "dev"
    Then the value MUST equal the value of "${XIANYUN_CLUSTER_NAME}"

  Scenario: Command substitution is not evaluated
    Given a config value "$(touch /tmp/x)"
    When the config is loaded
    Then the value MUST remain "$(touch /tmp/x)"
    And no file "/tmp/x" MUST exist

  Scenario: Defaults belong in variable definitions
    Given a config value "${HOST:-fallback}" outside the "variables:" block
    When the config is loaded
    Then loading MUST fail with an unsupported-expansion error

Tests: crates/xianyun-core/src/variables/expand.rs (including expand_string_rejects_unsupported_expansions), resolver.rs, predefined.rs (unit). Security: see SR-007.

FR-003: Configuration commands

Status: Implemented (migrate can only report “already latest”)

  • FR-003.1 When the user runs config validate, the system SHALL load and validate the configuration and print a summary, exiting 0 on success.
  • FR-003.2 When the user runs config print, the system SHALL print the expanded configuration.
  • FR-003.3 When the user runs config migrate, the system SHALL report “already latest” for version 2 configurations and SHALL write a backup before changing a file unless --skip-backup is given.
Feature: Configuration commands

  Scenario: Print echoes the driver
    Given a valid mock-driver config
    When the user runs "xianyun config print"
    Then the output MUST contain "mock"

Tests: crates/xianyun-cli/tests/config.rs, nix/tests/e2e-mock.nix.

FR-004: Cluster lifecycle

Status: Partial (status fails with reloaders, GAP-002; up is slow when the cluster can’t pull images, GAP-009) Rationale: ADR-0008

  • FR-004.1 When the user runs up, the system SHALL start the cluster through the configured driver, fetch its kubeconfig, wait until the API server answers, and then run the deploy sequence (FR-005).
  • FR-004.2 When the user runs down, the system SHALL stop the cluster and SHALL exit 0 when none was running.
  • FR-004.3 When the user runs destroy, the system SHALL delete the cluster and its state and SHALL exit 0 when none exists.
  • FR-004.4 When the user runs status, the system SHALL report the driver state.
  • FR-004.5 If the driver is external, then down and destroy SHALL return an error stating that Xianyun doesn’t own the cluster’s lifecycle.
  • FR-004.6 Where the driver is auto, the system SHALL select k3d on Linux and colima elsewhere.
Feature: Cluster lifecycle

  Scenario: Down and destroy are idempotent
    Given a mock-driver config and a cluster that was never started
    When the user runs "xianyun down" and then "xianyun destroy"
    Then both exit codes MUST be 0

  Scenario: Real k3d lifecycle
    Given a k3d-driver config and docker available
    When the user runs "xianyun up"
    Then "kubectl get nodes" MUST report a Ready node
    When the user runs "xianyun destroy"
    Then no k3d cluster for the profile MUST remain

Tests: crates/xianyun-cli/tests/up.rs (16 ignored live-cluster tests across up, down, destroy, status, update, deploy; a 17th ignored test in up.rs covers cancellation), crates/xianyun-cluster/tests/*_driver.rs, nix/tests/e2e-mock.nix, nix/tests/e2e-k3d.nix, nix/tests/e2e-deploy.nix, nix/tests/e2e-cells.nix.

FR-005: Deployer orchestration

Status: Partial (the builtin: overlays of FR-005.5 haven’t been deployed end to end)

  • FR-005.1 When up or deploy runs, the system SHALL deploy each configured deployer after its needs dependencies, in topological order.
  • FR-005.2 The system SHALL support deployer types helm, kustomize, and namespace.
  • FR-005.3 If a deployer type has no registered manager, then the system SHALL fail with an error naming the type.
  • FR-005.4 When deploy --repo <name> is given, the system SHALL deploy only the named deployer. (Despite its name the flag takes a deployer name; the e2e-deploy scenario confirms this.)
  • FR-005.5 The builtin:dev-ca and builtin:traefik-tlsstore kustomize targets SHALL be available without the user supplying overlay files. (Not exercised end to end: they need cert-manager’s CRDs and a Traefik, which the VM tests can’t pull.)
  • FR-005.6 If a deployer fails, then the system SHALL stop, exit non-zero, and name that deployer.
Feature: Deployer orchestration

  Scenario: A namespace deployer creates its namespace
    Given a running cluster and a config with a "namespace" deployer for "demo"
    When the user runs "xianyun deploy"
    Then "kubectl get namespace demo" MUST succeed

  Scenario: Dependencies run first
    Given deployer "b" with needs ["a"]
    When the user runs "xianyun deploy"
    Then "a" MUST complete before "b" starts

  Scenario: A broken deployer is named
    Given a helm deployer "broken-chart" whose chart doesn't exist
    When the user runs "xianyun deploy"
    Then the exit code MUST be non-zero
    And the output MUST name "broken-chart"

Tests: crates/xianyun-deploy/src/deploy/orchestrator.rs and the three managers’ unit tests; crates/xianyun-cli/tests/deploy_registry.rs (the registry is populated in production); the e2e-deploy NixOS VM test, which runs all three scenarios above against a real k3d cluster, and also repeats a deploy (the Helm upgrade path) and checks --repo.

FR-006: Edit mode and reloaders

Status: Partial (GAP-002, GAP-006)

  • FR-006.1 When the user runs run, the system SHALL start every configured reloader concurrently and SHALL not return success until each has finished.
  • FR-006.2 When the process receives an interrupt or termination signal, the system SHALL stop waiting for reloaders and SHALL run stop hooks.
  • FR-006.3 The system SHALL support a mirror reloader (mirrord) and a skaffold reloader.
  • FR-006.4 Where a mirror reloader lists edit_mode_processes, the system SHALL run each through the process supervisor.
Feature: Edit mode

  Scenario: Reloaders are joined before exit
    Given two reloaders that each take one second
    When the user runs "xianyun run" and sends an interrupt
    Then the process MUST NOT exit until both tasks have returned or been abandoned deliberately

Tests: crates/xianyun-cli/tests/run_concurrency.rs, run.rs, editmodeprocesssupervisor.rs.

FR-007: Repository update

Status: Implemented

  • FR-007.1 When the user runs update, the system SHALL clone missing repositories, fetch existing ones, and fast-forward only.
  • FR-007.2 Where --verify-only (alias --dry-run) is given, the system SHALL report what would change and SHALL NOT modify any repository.
  • FR-007.3 Where --stash is given, the system SHALL stash local changes before updating.
  • FR-007.4 If a fast-forward isn’t possible, then the system SHALL report the repository as not updated and SHALL leave it unchanged.
Feature: Repository update

  Scenario: Dry run changes nothing
    Given a clone that is behind its upstream
    When the user runs "xianyun update --dry-run"
    Then the clone's HEAD MUST be unchanged

Tests: crates/xianyun-update/src/updater.rs (unit), crates/xianyun-cli/tests/update.rs.

FR-008: Dev CA generation and trust

Status: Partial (FR-008.6 not met) Rationale: ADR-0006

  • FR-008.1 When a command needs the CA, the system SHALL load the current CA for the profile or generate an ECDSA P-256 CA when none exists.
  • FR-008.2 The system SHALL limit the CA with Name Constraints: always the default subtrees .local, .test, and .svc, plus the configured hostname and the cluster domain when neither falls under a default (see SR-011). Measured 2026-10-13: a config with hostname demo.test and cluster domain cluster.local produced [.local .test .svc].
  • FR-008.3 When the user runs ca trust, the system SHALL install the CA certificate in the system trust store and, where certutil is available, in NSS databases.
  • FR-008.4 When the user runs ca untrust, the system SHALL remove the certificate, including entries installed under the legacy Caproni name.
  • FR-008.5 When the constraints change, the system SHALL re-sign with the same key; if the CA has expired, then the system SHALL generate a new one.
  • FR-008.6 When loading a CA, the system SHALL verify that the certificate’s public key matches the private key. (Not met today; see TM-PKI-005.)
Feature: Dev CA

  Scenario: A CA is generated once
    Given no CA exists for profile "dev"
    When the user runs "xianyun ca inspect" twice
    Then both runs MUST report the same certificate fingerprint

  Scenario: The CA is constrained
    Given a config with hostname "gitlab.dev" and no cluster domain
    When the CA is generated
    Then its permitted DNS subtrees MUST be ".local", ".test", ".svc", and "gitlab.dev"
    And a config with hostname "demo.test" MUST add no hostname entry, because ".test" already covers it

Tests: crates/xianyun-pki/src/ca.rs, constraints.rs, truststore/* (unit, mocked CommandRunner), crates/xianyun-cli/tests/ca.rs. Security: SR-010, SR-011; TM-PKI-001 through TM-PKI-006.

FR-009: /etc/hosts management

Status: Implemented (SHOULD FR-009.5 not met) Rationale: ADR-0004

  • FR-009.1 When the user runs update-etc-hosts, the system SHALL compute the new file content and write it only if a hostname is missing or points elsewhere.
  • FR-009.2 Where --dry-run is given, the system SHALL NOT write the file.
  • FR-009.3 Where --remove is given, the system SHALL remove the configured hostnames.
  • FR-009.4 The system SHALL write /etc/hosts with elevated privileges only when the running user isn’t root.
  • FR-009.5 The system SHOULD replace the file atomically and keep a backup. (Not met today; see TM-HOSTS-002.)
Feature: /etc/hosts management

  Scenario: Dry run writes nothing
    Given a hostname missing from /etc/hosts
    When the user runs "xianyun update-etc-hosts --dry-run"
    Then /etc/hosts MUST be unchanged

Tests: crates/xianyun-backends/src/hosts/mod.rs, crates/xianyun-cli/tests/updateetchosts.rs. Security: SR-004.

FR-010: Kube context management

Status: Partial (FR-010.5 not met for the backup)

  • FR-010.1 When the user runs set-kube-context, the system SHALL merge the profile’s kubeconfig into the target file, preserving unrelated entries.
  • FR-010.2 The system SHALL write the kubeconfig atomically through a temporary file in the same directory.
  • FR-010.3 Where --use is given, the system SHALL make the profile’s context current.
  • FR-010.4 Where --remove is given, the system SHALL remove the profile’s entries.
  • FR-010.5 The system SHALL create the kubeconfig and its backup readable only by the owner (see SR-010).
Feature: Kube context

  Scenario: The backup is private
    Given an existing kubeconfig with mode 0600
    When the user runs "xianyun set-kube-context"
    Then the backup file's mode MUST be 0600

Tests: crates/xianyun-cluster/tests/kubeconfig_file.rs, kubeconfig_merge.rs, crates/xianyun-cli/tests/setkubecontext.rs. The backup-mode scenario currently fails (measured: 0644).

FR-011: Registry cache lifecycle

Status: Partial (FR-011.6 not met) Rationale: ADR-0007

  • FR-011.1 Where registry_mirror.managed is true, the system SHALL start one zot instance on up, shared across profiles with reference counting.
  • FR-011.2 When destroy runs, the system SHALL release the profile’s reference and SHALL NOT stop zot.
  • FR-011.3 When the user runs registry stop, the system SHALL stop zot.
  • FR-011.4 Where registry_mirror.managed is false and an endpoint is set, the system SHALL write only the driver-side mirror configuration and SHALL NOT start any process.
  • FR-011.5 When the user runs registry status -o json, the system SHALL print machine-readable status.
  • FR-011.6 Before signalling a PID from the PID file, the system SHALL verify the value is greater than 1. (Not met today; see TM-REG-002.)
Feature: Registry cache

  Scenario: Destroy keeps the cache
    Given a managed zot started by profile "a"
    When the user runs "xianyun destroy" for profile "a"
    Then zot MUST still be running

Tests: crates/xianyun-deploy/src/registrycache/* (unit), crates/xianyun-cli/tests/registry.rs.

FR-012: Doctor diagnostics

Status: Partial (GAP-003)

  • FR-012.1 When the user runs doctor, the system SHALL run each registered diagnostic and report pass or fail with a reason.
  • FR-012.2 The system SHALL provide diagnostics for cluster domain and orphaned mirror copies.
  • FR-012.3 The system SHOULD provide diagnostics for disk space, macOS firewall, and node health, matching the Go tool.
Feature: Doctor

  Scenario: Doctor does not panic with tools missing
    Given a machine with none of helm, kustomize, git, k3d, or docker
    When the user runs "xianyun doctor"
    Then the output MUST NOT contain "panicked at"

Tests: crates/xianyun-cli/tests/doctor.rs, nix/tests/e2e-mock.nix.

FR-013: Lifecycle hooks

Status: Partial (FR-013.5 not met)

  • FR-013.1 The system SHALL support hook types edit_mode_prepare, edit_mode_start, and edit_mode_stop.
  • FR-013.2 The system SHALL split a hook’s script into arguments with quote handling and run it without a shell.
  • FR-013.3 The system SHALL give a hook the parent environment plus the resolver variables and KUBECONFIG.
  • FR-013.4 If a script has an unterminated quote, then the system SHALL fail before running it.
  • FR-013.5 The system SHALL use one shell-word splitting implementation for hooks and for edit-mode process commands. (Not met today; see TM-EXEC-002.)
Feature: Hooks

  Scenario: No shell expansion
    Given a hook script "echo $HOME; touch /tmp/x"
    When the hook runs
    Then the program "echo" MUST receive the literal arguments
    And no file "/tmp/x" MUST be created

Tests: crates/xianyun-update/src/lifecyclehooks.rs. Security: TM-EXEC-001 through TM-EXEC-003, SR-008.

FR-014: MCP tool surface and gating

Status: Partial (FR-014.4 isn’t met when the first call already carries an answer; see SR-003) Rationale: ADR-0005

  • FR-014.1 The system SHALL expose exactly the commands named in the allowlist and no others.
  • FR-014.2 If a CLI subcommand is added without being classified as exposed or excluded, then the build SHALL fail.
  • FR-014.3 The system SHALL NOT expose run, tool, kubectl, k9s, stern, or edit-mode-process-supervisor.
  • FR-014.4 When a gated tool is called without a confirmation answer, the system SHALL return an input-required result and SHALL NOT run the command. A call that carries an answer the server didn’t ask for SHALL be treated as a call without one. (Not met: measured 2026-10-13, a first call with a forged accept runs the command.)
  • FR-014.5 If a client that can’t answer elicitation calls a gated tool, then the system SHALL return a tool error and SHALL NOT run the command.
  • FR-014.6 Where the stdio transport is used, the system SHALL read and write the protocol on standard input and output only.
Feature: MCP gating

  Scenario: A gated tool needs confirmation
    Given a connected MCP client
    When the client calls "destroy" with no confirmation
    Then the result MUST be input-required
    And no cluster MUST be destroyed

  Scenario: A decline does nothing
    Given the client was asked to confirm "destroy"
    When the client answers "decline"
    Then the exit code MUST be 1
    And stderr MUST contain "aborted at the user's request"

Tests: crates/xianyun-mcp/tests/guardrail.rs, protocol.rs (tool list, an end-to-end version call, and a gated call from a client with no elicitation capability; nothing tests the accept or decline retry), golden_tools.rs.

FR-015: Passthrough tools

Status: Implemented

  • FR-015.1 When the user runs kubectl, k9s, stern, or tool, the system SHALL run the program with KUBECONFIG set to the profile’s kubeconfig and the remaining arguments unchanged.
  • FR-015.2 When the user runs logs, the system SHALL print the last --tail lines (default 25) from the named processes’ logs.
Feature: Passthrough

  Scenario: kubectl sees the profile's cluster
    Given a profile with a kubeconfig
    When the user runs "xianyun kubectl config current-context"
    Then the printed context MUST belong to the profile

Tests: crates/xianyun-cli/tests/kubectl.rs, k9s.rs, stern.rs, logs.rs, crates/xianyun-cluster/tests/clustertool.rs.

Interface requirements

IR-001: Exit codes

  • IR-001.1 The system SHALL exit 0 on success and non-zero on any error.
  • IR-001.2 The system SHALL return errors from library code as values and SHALL NOT call std::process::exit outside main.

Tests: crates/xianyun-cli/src/exitcode.rs; all CLI tests assert exit status.

IR-002: Machine-readable output

  • IR-002.1 Where a command offers -o json, the system SHALL print valid JSON on standard output and logs on standard error.
  • IR-002.2 The system SHOULD offer structured output on status, doctor, and version. (Not met; only registry status has it. The vision document lists this as the next phase.)

Tests: crates/xianyun-cli/tests/registry.rs.

Non-functional requirements

NFR-001: Behavioural parity with the Go tests

The system SHALL keep a test in the Rust workspace for each behaviour the Go test suite verifies, and SHALL document each ignored test with a reason. Measured: 1,142 tests on Linux (1,119 pass, 0 fail, 23 ignored; 22 have a documented reason and one, compile_schema_lazy_loading, is an unimplemented stub). Line coverage was reported as about 90 percent in crates/README.md; that figure wasn’t re-measured in this review.

NFR-002: Process execution goes through CommandRunner without a shell

The system SHALL start external programs through CommandRunner and SHALL NOT invoke a shell to interpret configuration values. Six raw std::process::Command call sites in five files exist outside the seam (listed in ADR-0004); each SHOULD move behind the trait or be justified in code.

NFR-003: Platform support

The system SHALL build and pass its tests on x86_64-linux, aarch64-linux, and aarch64-darwin. Linux was measured in this review. The macOS figures that the macOS notes and crates/README.md used to quote disagreed (1,076 and 1,090) and weren’t re-measured.

NFR-004: Reproducible development environment

The repository SHALL provide a flake.nix development shell with the toolchain and the external tools Xianyun shells out to. nix develop -c cargo build -p xianyun-cli -p xianyun-mcp built cleanly on 2026-10-13, and the devShell also provides cargo-audit and cargo-deny.

NFR-005: The tool collects no telemetry

Status: Implemented

The system SHALL NOT collect or transmit usage data, and the codebase SHALL NOT contain a telemetry client, event types, or a switch for one. A future change that adds telemetry needs a new ADR that supersedes ADR-0018. Checked 2026-10-13 by searching crates/ for telemetry and for the endpoint host the removed code used: no matches outside the porting notes. (See TM-TEL-001, eliminated.)

NFR-006: Config parsing is bounded

The system SHALL reject configuration input larger than a documented limit before parsing and SHOULD move off the deprecated serde_yaml crate. (See TM-CFG-006, TM-SUP-002.)

NFR-007: Dependencies are scanned in CI

Status: Partial (committed and verified locally; not yet run in CI)

The repository SHALL run cargo test --workspace, cargo clippy, and a vulnerability scan (cargo audit or cargo deny) in CI. .gitlab-ci.yml now runs cargo fmt --check, cargo clippy -D warnings, cargo test --workspace, cargo deny check, the example validation, and the documentation build, and deny.toml holds the policy. Each command passes locally; the pipeline hasn’t run on GitLab yet, and the weekly advisory schedule has to be created in the project settings. Measured on 2026-10-13: cargo audit reports 0 vulnerabilities and 4 warnings (see TM-SUP-001).

Security requirements

Status values: Not Started, Partial, Implemented.

SR-001: MCP HTTP binds loopback by default

When mcp stream is started without --host, the system SHALL bind 127.0.0.1, and SHALL refuse a non-loopback address unless authentication is configured. The 0.0.0.0 default comes from the brontes dependency, so the fix is a wrapper in xianyun-mcp or an upstream change. Threat: TM-MCP-001 Status: Partial. A Host header allow-list exists in rmcp; the default bind was measured as 0.0.0.0 and there’s no authentication.

SR-002: MCP gating covers hook execution

If the resolved configuration contains lifecycle hooks or edit-mode repositories, then up, deploy, and update SHALL require confirmation and SHALL show the hook commands in the prompt. Threat: TM-MCP-002 Status: Not Started

SR-003: MCP confirmations bind to the exact call

The system SHALL accept a confirmation only if it answers a challenge the server issued for the same command and arguments, and SHALL reject it after one use. A first call that carries an answer SHALL be answered with the challenge, not executed. Threat: TM-MCP-003 Status: Not Started. Measured: a first call carrying input_responses = {"confirm": {"action": "accept"}} ran xianyun down.

SR-004: Privileged inputs are validated

The system SHALL parse --ip as an IP address before use, SHALL reject hostnames containing whitespace, SHALL validate --kubeconfig paths for MCP calls, and SHOULD ask for confirmation of the hostname list before the first /etc/hosts write. Threats: TM-HOSTS-001, TM-HOSTS-003, TM-MCP-004 Status: Not Started. Measured: an --ip with an embedded newline produced an extra host entry in rendered content.

SR-005: MCP annotations reflect actual behaviour

The system SHALL set openWorldHint: true for tools that can fetch remote fragments, repositories, or charts. Threat: TM-MCP-005 Status: Not Started

SR-006: Remote config is pinned or allowlisted

The system SHALL accept a remote fragment only over https (including relative references inside it), SHALL support a content hash per fragment, and SHALL write the fetch cache privately. Threats: TM-CFG-001, TM-CFG-002 Status: Partial. extends is https-only and size-capped; pinning and relative-reference checks aren’t implemented.

SR-007: Config expansion does not expose the ambient environment

The system SHALL expand only declared variables, predefined variables, and an explicit allowlist from the environment, and SHALL NOT print undeclared environment values in config print. Threat: TM-CFG-003 Status: Not Started. Measured: an exported variable was expanded and printed.

SR-008: Project files from cloned repositories need opt-in

The system SHALL load xianyun-project.yaml hooks and reloaders from a cloned repository only when that repository’s configuration opts in, SHALL show added hooks when they first appear or change, and SHALL stop config discovery at the repository root or the home directory. Threats: TM-CFG-004, TM-CFG-007 Status: Not Started

SR-009: Positional arguments cannot be parsed as options

The system SHALL insert -- before user-supplied positional arguments to git, helm, and kustomize where the tool supports it, and SHALL reject values that start with -. Threat: TM-CFG-005 Status: Not Started

SR-010: Secret-bearing files are created private

The system SHALL create CA key material, kubeconfig backups, the temporary kubeconfigs written for each helm and kubectl call, kustomize staging directories containing secrets, and remote-fragment caches with mode 0600 for files and 0700 for directories. Threats: TM-PKI-001, TM-PKI-004, TM-KUBE-001, TM-KUBE-002 Status: Partial. ca.key and the kubeconfig are 0600; the backup (measured 0644), the staging directory (755) and its key (644), the temporary kubeconfigs (default mode, not measured), and the PKI directories aren’t.

SR-011: The dev CA is always name-constrained

The system SHALL refuse to generate a CA without Name Constraints, including when PkiCaProvider::load generates one on a profile that has none (it passes an empty permitted list today), and SHALL reject cluster-reported zones that are a single label or a public suffix. Threats: TM-PKI-002, TM-PKI-003, TM-PKI-006 Status: Partial. Constraints are applied only when the permitted list is non-empty (ca.rs:290).

SR-012: The registry cache isn’t reachable from the network

The managed zot SHALL bind a loopback or bridge address, and SHOULD set an access policy that denies anonymous writes. Threats: TM-REG-001, TM-REG-003 Status: Not Started. The configuration sets 0.0.0.0 and has no access-control section.

SR-013: Images Xianyun runs are pinned by digest

The system SHALL reference every image it runs in the cluster by digest. Threat: TM-EXEC-004 Status: Not Started

SR-014: Privileged and agent-initiated actions are recorded

The system SHALL append a structured record of each privileged operation and each MCP tool call, including the confirmation outcome, to a file in the data directory. Threat: TM-AUD-001 Status: Not Started

End-to-end scenarios

The Go project’s integration suites drove its binary against real k3d clusters and were removed with the Go tree (ADR-0017). Five of the seven scenarios came back as NixOS VM tests built on nix/tests/lib.nix (ADR-0019). Each was run once to a pass on 2026-10-14, on Linux with KVM, against the final code. The two that need edit mode are blocked by GAP-002.

Go suiteNixOS VM testStatusWhat the VM version checks, and how it differs
basice2e-deployPassingA namespace, a Kustomize overlay, and a Helm chart install in dependency order on a real cluster; a second deploy upgrades; --repo runs only the named deployer; a broken chart fails and is named. The workloads are ConfigMaps, because the VM can’t pull an application image.
bundled-ingresse2e-no-ingressPassingA control cluster shows k3s deploys Traefik by default; with k3s.disable: [traefik] the flag reaches the server’s arguments, no Traefik chart is deployed, and the project’s own deployer is what’s installed. The “own ingress” is a ConfigMap chart, not a real controller.
ca-drifte2e-ca-driftPassingChanging the hostname re-signs the CA with the same public key; a leaf issued earlier still verifies; a leaf for the new name verifies; a leaf for evil.example.com is rejected by the name constraints; ca inspect lists the new constraint. Checked with openssl, outside Xianyun.
cellse2e-cellsPassingTwo directories with directory-hash naming become two clusters on distinct host ports, each with its own identity ConfigMap; destroying one leaves the other running. The Go version served an HTTP endpoint per cell; this one can’t.
registry-cachee2e-registry-mirrorPassingA zot container is the bring-your-own cache. The node’s registries.yaml names it, the node gets an HTTP 200 from it, a pull of a tag the cache lacks fails as a pull error, a pull of a seeded tag succeeds, and the cache’s log shows the request. Managed mode isn’t covered.
mirrornoneBlockedThe mirror reloader, with mirrord. Needs the process supervisor (GAP-002).
mirror-projectnoneBlockedThe same, with the reloader supplied by a xianyun-project.yaml.

The Go suite that validated every example is replaced by scripts/validate-examples.sh, which CI runs.

Implementation tasks

Each task closes a gap or security requirement above. A task that says “Done” has shipped; for the others, test files named here don’t exist yet.

Task 1: Register production deployer managers (GAP-001)

Done 2026-10-14: register_deployers() in crates/xianyun-cli/src/drivers.rs, the deployer::Manager trait implemented for the three managers, and a Helm undeploy (which the Rust port had never had). Tests: crates/xianyun-cli/tests/deploy_registry.rs, four undeploy tests in crates/xianyun-deploy/src/deployer/helm/manager.rs, and e2e-deploy.

Task 2: Bind the MCP HTTP transport to loopback by default (SR-001)

File: crates/xianyun-mcp/src/main.rs File: crates/xianyun-mcp/src/lib.rs Test File: crates/xianyun-mcp/tests/bind_default.rs

Task 3: Validate privileged inputs (SR-004)

File: crates/xianyun-cli/src/commands/updateetchosts.rs File: crates/xianyun-backends/src/hosts/mod.rs

#![allow(unused)]
fn main() {
#[test]
fn update_rejects_ip_with_embedded_newline() {
    let err = parse_ip("1.2.3.4 x\n6.6.6.6 gitlab.com").unwrap_err();
    assert!(err.to_string().contains("not a valid IP"));
}
}

Task 4: Create secret-bearing files private (SR-010)

File: crates/xianyun-cluster/src/kubeconfig/file.rs File: crates/xianyun-backends/src/kustomize/mod.rs File: crates/xianyun-pki/src/ca.rs Test File: crates/xianyun-cluster/tests/kubeconfig_backup_mode.rs

Task 5: Stop expanding the ambient environment (SR-007)

File: crates/xianyun-core/src/variables/resolver.rs

Task 6: Add a Rust CI job and a dependency scan (NFR-007)

Done in the working tree on 2026-10-13, awaiting a first pipeline run.

File: .gitlab-ci.yml File: flake.nix

Task 7: Make confirmations single-use and bound to the call (SR-003)

File: crates/xianyun-mcp/src/config.rs Test File: crates/xianyun-mcp/tests/forged_confirmation.rs

The test is the measurement behind TM-MCP-003 made permanent: spawn the real binary, send xianyun_down with input_responses = {"confirm": {"action": "accept"}} on the first call, and assert the result is an input-required response and that no command ran.

Requirements traceability matrix

RequirementStatusTestsADRThreats
FR-001Partialxianyun-core unit, cli/tests/config.rs, e2e-mock0003, 0009TM-CFG-001 to TM-CFG-007
FR-002Implementedcore/src/variables/*0009TM-CFG-003
FR-003Implementedcli/tests/config.rs, e2e-mock0009TM-CFG-003
FR-004Partialcli/tests/up.rs (ignored live), cluster/tests/*, e2e-mock, e2e-k3d0008TM-EXEC-004
FR-005Partialdeploy/src/deploy/orchestrator.rs, cli/tests/deploy_registry.rs, e2e-deploy0002, 0008, 0019none
FR-006Partialcli/tests/run*.rsnoneTM-EXEC-001, TM-EXEC-003
FR-007Implementedupdate/src/updater.rs, cli/tests/update.rsnoneTM-CFG-004, TM-CFG-005
FR-008Partialpki/src/*, cli/tests/ca.rs0006TM-PKI-001 to TM-PKI-006
FR-009Implemented (SHOULD unmet)backends/src/hosts, cli/tests/updateetchosts.rs0004TM-HOSTS-001 to TM-HOSTS-003
FR-010Partialcluster/tests/kubeconfig_*.rs, cli/tests/setkubecontext.rs0003TM-KUBE-001, TM-KUBE-002
FR-011Partialdeploy/src/registrycache/*, cli/tests/registry.rs0007TM-REG-001 to TM-REG-003
FR-012Partialcli/tests/doctor.rsnonenone
FR-013Partialupdate/src/lifecyclehooks.rs0004TM-EXEC-001 to TM-EXEC-003
FR-014Partialmcp/tests/*0005TM-MCP-001 to TM-MCP-005, TM-AUD-001
FR-015Implementedcli/tests/kubectl.rs and othersnonenone
IR-001Implementedall CLI testsnonenone
IR-002Partialcli/tests/registry.rs0002none
NFR-001Implementedwhole workspace0002, 0008none
NFR-002Partialtestutil, backends0004TM-CFG-005
NFR-003PartialLinux measured; macOS reported0008none
NFR-004Implementedflake.nix0008none
NFR-005Implementedsearch of crates/0018TM-TEL-001 (eliminated)
NFR-006Not Startednone0009TM-CFG-006, TM-SUP-002
NFR-007Partial.gitlab-ci.yml, deny.toml0008, 0017TM-SUP-001
SR-001 to SR-014see eachsee eachsee eachsee each

Gaps in the matrix: FR-006, FR-012, FR-015, and IR-001 have no ADR; NFR-006 has no test.

Changelog

DateChange
2026-10-13Initial retroactive specification, created by the all-aboard review