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.
Related documents
- Threat model
- ADR-0001 through ADR-0009
- Stories
- Caproni’s design documents: the Go-era design this project ported, at the pinned upstream commit
Definitions
| Term | Meaning |
|---|---|
| Profile | A named environment; selects cluster name, data directory, and CA |
| Driver | The cluster backend: auto, k3d, colima, vcluster, external, or mock |
| Deployer | A configured unit that installs something into the cluster: helm, kustomize, or namespace |
| Reloader | A configured edit-mode process manager: mirror (mirrord) or skaffold |
| Hook | A command in lifecycle_hooks run before or around edit mode |
| Gated tool | An MCP tool that needs an elicitation answer before it runs |
Known gaps
| ID | Gap | Evidence |
|---|---|---|
| GAP-001 | Closed 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-002 | edit-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-003 | doctor 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-004 | Closed 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-006 | run doesn’t prepare or clean up mirror deployment copies or check preconditions. | CODE: run.rs, collect_mirror_namespaces is ported and unused. |
| GAP-007 | up 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-008 | User-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-009 | Cluster 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-010 | Closed 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 tocaproni.yamlin the same directory when noxianyun.yamlexists 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.yamlandcaproni.yamlexist in one directory, the system SHALL use thexianyun.*file. - FR-001.3 The system SHALL merge
xianyun.local.yaml(orcaproni.local.yaml),extends:fragments, andxianyun-project.yaml(orcaproni-project.yaml) from edit-mode repositories into the base configuration, appending list values forlifecycle_hooks,additional_hostnames, anddynamic_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 [...]). Acluster.networkblock 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
$VARand${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, andXIANYUN_CLUSTER_DOMAIN, and SHALL treat theCAPRONI_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, repositoryref).
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-backupis 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, thendownanddestroySHALL return an error stating that Xianyun doesn’t own the cluster’s lifecycle. - FR-004.6 Where the driver is
auto, the system SHALL selectk3don Linux andcolimaelsewhere.
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
upordeployruns, the system SHALL deploy each configured deployer after itsneedsdependencies, in topological order. - FR-005.2 The system SHALL support deployer types
helm,kustomize, andnamespace. - 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; thee2e-deployscenario confirms this.) - FR-005.5 The
builtin:dev-caandbuiltin:traefik-tlsstorekustomize 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
mirrorreloader (mirrord) and askaffoldreloader. - 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
--stashis 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 hostnamedemo.testand cluster domaincluster.localproduced[.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, wherecertutilis 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-runis given, the system SHALL NOT write the file. - FR-009.3 Where
--removeis given, the system SHALL remove the configured hostnames. - FR-009.4 The system SHALL write
/etc/hostswith 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
--useis given, the system SHALL make the profile’s context current. - FR-010.4 Where
--removeis 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.managedis true, the system SHALL start one zot instance onup, shared across profiles with reference counting. - FR-011.2 When
destroyruns, 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.managedis false and anendpointis 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, andedit_mode_stop. - FR-013.2 The system SHALL split a hook’s
scriptinto 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, oredit-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
acceptruns 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, ortool, the system SHALL run the program withKUBECONFIGset to the profile’s kubeconfig and the remaining arguments unchanged. - FR-015.2 When the user runs
logs, the system SHALL print the last--taillines (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::exitoutsidemain.
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, andversion. (Not met; onlyregistry statushas 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 suite | NixOS VM test | Status | What the VM version checks, and how it differs |
|---|---|---|---|
basic | e2e-deploy | Passing | A 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-ingress | e2e-no-ingress | Passing | A 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-drift | e2e-ca-drift | Passing | Changing 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. |
cells | e2e-cells | Passing | Two 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-cache | e2e-registry-mirror | Passing | A 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. |
mirror | none | Blocked | The mirror reloader, with mirrord. Needs the process supervisor (GAP-002). |
mirror-project | none | Blocked | The 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
| Requirement | Status | Tests | ADR | Threats |
|---|---|---|---|---|
| FR-001 | Partial | xianyun-core unit, cli/tests/config.rs, e2e-mock | 0003, 0009 | TM-CFG-001 to TM-CFG-007 |
| FR-002 | Implemented | core/src/variables/* | 0009 | TM-CFG-003 |
| FR-003 | Implemented | cli/tests/config.rs, e2e-mock | 0009 | TM-CFG-003 |
| FR-004 | Partial | cli/tests/up.rs (ignored live), cluster/tests/*, e2e-mock, e2e-k3d | 0008 | TM-EXEC-004 |
| FR-005 | Partial | deploy/src/deploy/orchestrator.rs, cli/tests/deploy_registry.rs, e2e-deploy | 0002, 0008, 0019 | none |
| FR-006 | Partial | cli/tests/run*.rs | none | TM-EXEC-001, TM-EXEC-003 |
| FR-007 | Implemented | update/src/updater.rs, cli/tests/update.rs | none | TM-CFG-004, TM-CFG-005 |
| FR-008 | Partial | pki/src/*, cli/tests/ca.rs | 0006 | TM-PKI-001 to TM-PKI-006 |
| FR-009 | Implemented (SHOULD unmet) | backends/src/hosts, cli/tests/updateetchosts.rs | 0004 | TM-HOSTS-001 to TM-HOSTS-003 |
| FR-010 | Partial | cluster/tests/kubeconfig_*.rs, cli/tests/setkubecontext.rs | 0003 | TM-KUBE-001, TM-KUBE-002 |
| FR-011 | Partial | deploy/src/registrycache/*, cli/tests/registry.rs | 0007 | TM-REG-001 to TM-REG-003 |
| FR-012 | Partial | cli/tests/doctor.rs | none | none |
| FR-013 | Partial | update/src/lifecyclehooks.rs | 0004 | TM-EXEC-001 to TM-EXEC-003 |
| FR-014 | Partial | mcp/tests/* | 0005 | TM-MCP-001 to TM-MCP-005, TM-AUD-001 |
| FR-015 | Implemented | cli/tests/kubectl.rs and others | none | none |
| IR-001 | Implemented | all CLI tests | none | none |
| IR-002 | Partial | cli/tests/registry.rs | 0002 | none |
| NFR-001 | Implemented | whole workspace | 0002, 0008 | none |
| NFR-002 | Partial | testutil, backends | 0004 | TM-CFG-005 |
| NFR-003 | Partial | Linux measured; macOS reported | 0008 | none |
| NFR-004 | Implemented | flake.nix | 0008 | none |
| NFR-005 | Implemented | search of crates/ | 0018 | TM-TEL-001 (eliminated) |
| NFR-006 | Not Started | none | 0009 | TM-CFG-006, TM-SUP-002 |
| NFR-007 | Partial | .gitlab-ci.yml, deny.toml | 0008, 0017 | TM-SUP-001 |
| SR-001 to SR-014 | see each | see each | see each | see each |
Gaps in the matrix: FR-006, FR-012, FR-015, and IR-001 have no ADR; NFR-006 has no test.
Changelog
| Date | Change |
|---|---|
| 2026-10-13 | Initial retroactive specification, created by the all-aboard review |