+) : }
+
diff --git a/src/components/SiteTitle.astro b/src/components/SiteTitle.astro
new file mode 100644
index 00000000..fc44192e
--- /dev/null
+++ b/src/components/SiteTitle.astro
@@ -0,0 +1,14 @@
+---
+const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
+---
+
+
+
+ {siteTitle}
+
+
diff --git a/src/content.config.ts b/src/content.config.ts
index d9ee8c9d..6abb1600 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -1,7 +1,8 @@
import { defineCollection } from 'astro:content';
-import { docsLoader } from '@astrojs/starlight/loaders';
-import { docsSchema } from '@astrojs/starlight/schema';
+import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders';
+import { docsSchema, i18nSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
+ i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }),
};
diff --git a/src/content/docs/recipes/agentteams/agentteams-cli.md b/src/content/docs/recipes/agentteams/agentteams-cli.md
index 4c06ebed..b4cd2c05 100644
--- a/src/content/docs/recipes/agentteams/agentteams-cli.md
+++ b/src/content/docs/recipes/agentteams/agentteams-cli.md
@@ -46,7 +46,7 @@ resolves the controller's in-venue :8090 to a host-routable address
over the reverse channel (a published port on the pod substrate, a
live `ssh -L` forward on the vm substrate), pulls the admin SA token
from the venue, and probes with the SAME REST client the command uses
-(R3 — one surface covers the CLI and every bed). Methods:
+(R2 — one surface covers the CLI and every bed). Methods:
- `agentteams: status` — controller health + resource counts.
- `agentteams: manager-running` — poll until a manager reaches Running
diff --git a/src/content/docs/recipes/agentteams/agentteams.md b/src/content/docs/recipes/agentteams/agentteams.md
index 01f4657f..1b2888f0 100644
--- a/src/content/docs/recipes/agentteams/agentteams.md
+++ b/src/content/docs/recipes/agentteams/agentteams.md
@@ -92,7 +92,7 @@ Host mappings auto-allocate at deploy and resolve as `${HOST_PORT:}`.
### Pod substrate
-`charly box build agentteams` then `charly fleet add` / `charly start` — or
+`charly box build agentteams` then `charly deploy add` / `charly start` — or
run the disposable bed end to end:
```
diff --git a/src/content/docs/recipes/automation/agent.md b/src/content/docs/recipes/automation/agent.md
index 118e7a24..586b389f 100644
--- a/src/content/docs/recipes/automation/agent.md
+++ b/src/content/docs/recipes/automation/agent.md
@@ -79,7 +79,7 @@ Never retry an agent failure automatically. Record the incident, collect ordered
- Expect phase/status events such as endpoint bootstrap, `waiting-for-prompt`, `prompt-ready`, running/reattached, settled, and exit.
- Treat silence, malformed frames, EOF before ACK, cleanup failure, warnings, and unexpected duration as incidents.
-- Use no sleep, polling loop, backoff, or arbitrary timed retry — per the project rulebook R4. Synchronize on process, tmux control, gRPC, terminal, and context events.
+- Use no sleep, polling loop, backoff, or arbitrary timed retry — per the project rulebook R3. Synchronize on process, tmux control, gRPC, terminal, and context events.
- Preserve stdout for structured command results and use stderr for provider/operation/target progress and actionable failures.
- For implementation acceptance, run the complete unmodified disposable R10 bed with `charly check run ` and use its per-step logs and summary.
diff --git a/src/content/docs/recipes/automation/crabbox-deploy.md b/src/content/docs/recipes/automation/crabbox-deploy.md
index fd189072..2285284a 100644
--- a/src/content/docs/recipes/automation/crabbox-deploy.md
+++ b/src/content/docs/recipes/automation/crabbox-deploy.md
@@ -24,7 +24,7 @@ CLI -> broker at loopback :8080 -> postgres :5432 -> leases via
| Action | Command | Description |
|--------|---------|-------------|
-| CLI version | `crabbox --version` | Verified 0.48.1 pin |
+| CLI version | `crabbox --version` | The pin is owned by `layer-crabbox`'s `CRABBOX_VERSION` — never restate a version here |
| Doctor | `crabbox doctor` | Local prerequisites; exit 0 needs NO broker (or a reachable one) |
| Providers | `crabbox providers` | Credential-free local provider matrix (local-container/ssh/external) |
| Login (shared token) | `printf '%s' "$CRABBOX_SHARED_TOKEN" \| crabbox login --url http://127.0.0.1:8080 --token-stdin` | Broker auth |
@@ -75,6 +75,6 @@ charly check run check-crabbox-e2e-pod # steps=13 PASS (coordinator+broker)
## Cross-References
-- [`/charly-tools:crabbox`](/recipes/tools/crabbox/) — the CLI candy.
+- [`/charly-tools:crabbox-cli`](/recipes/tools/crabbox-cli/) — the CLI candy.
- [`/charly-check:crabbox`](/recipes/check/crabbox/) — the check verb.
- [`/charly-crabbox:crabbox-coordinator`](/recipes/crabbox/crabbox-coordinator/) — the coordinator candy.
diff --git a/src/content/docs/recipes/automation/openclaw-deploy.md b/src/content/docs/recipes/automation/openclaw-deploy.md
index 029111e8..86292b3f 100644
--- a/src/content/docs/recipes/automation/openclaw-deploy.md
+++ b/src/content/docs/recipes/automation/openclaw-deploy.md
@@ -1,6 +1,6 @@
---
title: "openclaw-deploy"
-description: "OpenClaw gateway configuration, model auth, browser integration, channel setup, or any box composing `openclaw-*` candies (`openclaw`, `openclaw-full`, or a custom box composing openclaw + sway-desktop)."
+description: "OpenClaw gateway configuration, model auth, browser integration, channel setup, or any box composing the OpenClaw family's candies (`openclaw`, or a custom box composing openclaw + sway-desktop)."
---
@@ -13,7 +13,7 @@ description: "OpenClaw gateway configuration, model auth, browser integration, c
OpenClaw is an AI gateway that connects LLM agents to messaging channels (WhatsApp, Telegram, Discord, Slack, Signal, iMessage, IRC, Teams) and exposes them via a WebSocket API, CLI, and web Control UI. It runs as a Node.js process with an embedded agent runtime, model failover, browser automation, and multi-agent routing.
-In OpenCharly, OpenClaw runs as a supervisord service inside containers. The `openclaw` candy provides the npm package; compose it with the `sway-desktop` metalayer (full Sway desktop + Chrome browser + VNC) for browser-based workflows like OAuth and web automation. For a desktop+browser box, compose your own from the candies (shown below); for non-browser channels use the headless `openclaw` / `openclaw-full` boxes.
+In OpenCharly, OpenClaw runs as a supervisord service inside containers. The `openclaw` candy provides the npm package; compose it with the `sway-desktop` metalayer (full Sway desktop + Chrome browser + VNC) for browser-based workflows like OAuth and web automation. For a desktop+browser box, compose your own — the family's `openclaw` candy plus `sway-desktop`, adding whichever tool candies you need; for non-browser channels use the headless `openclaw` box, which is the composition shown below.
The gateway listens on port 18789 (WebSocket + HTTP). All CLI commands (`openclaw *`) connect to the gateway WebSocket. The Control UI is served at the gateway root URL. Config is stored in `~/.openclaw/openclaw.json` (JSON5 format).
@@ -54,48 +54,45 @@ The gateway binds to loopback only (no `--bind lan`). External access is handled
### Box
-Compose your own desktop+browser box — e.g. an `openclaw-desktop` entry in `charly.yml` combining `openclaw-full` + `sway-desktop` on a Fedora base:
+The OpenClaw family owns this box: `opencharly/layer-openclaw` provides `box/openclaw`, a **headless** gateway box (no desktop, no browser, no tool bundle) composing exactly two candies — the gateway layer and the out-of-process `openclaw:` check verb. Import the family and deploy its box rather than hand-assembling the candies yourself:
```yaml
-openclaw-desktop:
+openclaw:
candy:
- base: fedora
+ base: cachyos.cachyos
candy:
- - '@github.com/opencharly/layer-agent-forwarding'
- - '@github.com/opencharly/layer-openclaw-full'
- - '@github.com/opencharly/layer-sway-desktop'
- - '@github.com/opencharly/pod-dbus'
- - charly
+ - openclaw
+ - '@github.com/opencharly/plugin-openclaw/candy/plugin-openclaw:v2026.281.0939'
```
-A box declares no ports of its own — the composed candies publish them (openclaw
-18789, wayvnc 5900, chrome 9222/9224) and `charly config -p` remaps them at deploy
-time. The resulting box exposes:
+A consumer that wants a desktop or a browser composes those candies on top.
+
+A box declares no ports of its own — the composed candies publish them (the headless
+family box exposes exactly one: the gateway on 18789) and `charly config -p` remaps
+them at deploy time. The resulting box exposes:
| Port | Service | Protocol |
|------|---------|----------|
-| 18789 | Gateway WebSocket + Control UI | HTTP |
-| 5900 | VNC (wayvnc) | TCP |
-| 9222 | Chrome DevTools | HTTP |
+| 18789 | OpenClaw gateway + Control UI | HTTP |
Tunnel config exposes all ports via Tailscale (`ports: all`). Platform: linux/amd64 only.
### Host Alias
```bash
-charly alias add openclaw openclaw-desktop
+charly alias add openclaw openclaw
# Now: openclaw --help (runs inside the container)
```
### Lifecycle
```bash
-charly box build openclaw-desktop # Build image
-charly config openclaw-desktop # Generate quadlet, daemon-reload
-charly start openclaw-desktop # Start via systemd
-charly stop openclaw-desktop # Stop
-charly status openclaw-desktop # Check systemd status
-charly logs openclaw-desktop -f # Follow container logs
+charly box build openclaw # Build image
+charly config openclaw # Generate quadlet, daemon-reload
+charly start openclaw # Start via systemd
+charly stop openclaw # Stop
+charly status openclaw # Check systemd status
+charly logs openclaw -f # Follow container logs
```
## Gateway Configuration
@@ -249,7 +246,7 @@ models: {
### Connecting to Existing Chrome
-In a composed openclaw desktop box (e.g. `openclaw-desktop`), Chrome already runs as a supervisord service on port 9222. OpenClaw must connect to it rather than launching a new instance:
+In a composed OpenClaw **desktop** box — one that composes `sway-desktop` and Chrome, which the family's own headless box does not — Chrome already runs as a supervisord service on port 9222. OpenClaw must connect to it rather than launching a new instance:
```bash
openclaw config set browser.cdpUrl "http://127.0.0.1:9222"
diff --git a/src/content/docs/recipes/automation/tmux.md b/src/content/docs/recipes/automation/tmux.md
index 94b61b32..27afe688 100644
--- a/src/content/docs/recipes/automation/tmux.md
+++ b/src/content/docs/recipes/automation/tmux.md
@@ -78,7 +78,7 @@ These commands never shell out recursively through `charly cmd`/`charly shell` a
- Control disconnect detaches when persistence permits. Initial reattachment and sequence recovery use a structured resynchronization snapshot.
- Normal zero exit or explicit close verifies server cleanup. Nonzero exit, malformed stream, evidence overflow, or cleanup failure creates an incident and performs no automatic restart.
- Recovery requires a completed RCA, except an explicit emergency abort that still records the unresolved incident.
-- Semantic readiness is driven by tmux control-output events. `waiting-for-prompt` and `prompt-ready` statuses make the boundary observable; no capture polling, sleeps, backoff, or timed retries — per R4, pace on observable state.
+- Semantic readiness is driven by tmux control-output events. `waiting-for-prompt` and `prompt-ready` statuses make the boundary observable; no capture polling, sleeps, backoff, or timed retries — per R3, pace on observable state.
## Terminal agents
diff --git a/src/content/docs/recipes/build/build.md b/src/content/docs/recipes/build/build.md
index 52b8a690..2c8045e3 100644
--- a/src/content/docs/recipes/build/build.md
+++ b/src/content/docs/recipes/build/build.md
@@ -13,13 +13,13 @@ Invoked as `charly box build`. See [`/charly-image:image`](/recipes/image/image/
## Overview
-`charly box build` generates Containerfiles from `charly.yml` and layer definitions, then builds images in dependency order using the configured build engine (Docker or Podman). Images at the same dependency level are built in parallel (up to `--jobs` concurrent builds).
+`charly box build` generates Containerfiles from `charly.yml` and layer definitions, then builds images in dependency order with **BuildKit**, charly's only image builder. Images at the same dependency level are built in parallel (up to `--jobs` concurrent builds).
**Mode purity**: `charly box build` reads `charly.yml` only. `charly.yml` is never read during build — this is enforced by `LoadConfig` in `charly/loader_threaded.go` (it reads the PROJECT `charly.yml` only and never merges the per-host `charly.yml` overlay — no `MergeDeployOverlay`), to guarantee OCI labels are baked strictly from authored configuration, never from local deploy-time overrides. See [`/charly-internals:go`](/recipes/internals/go/) "Mode purity" for the architectural invariant this protects and the bug it prevents.
-**Build-engine dispatch**: `charly box build` does not construct a generator host-side at all (the former `NewGenerator` is DELETED) — `BuildCmd.Run` computes the build tag, holds the build-activity retention lock, and dispatches through the COMPILED-IN `build:box` plugin (candy/plugin-build) over the F10 reverse channel; on return it runs the retention prune host-side. The plugin's `runBoxBuild` → `resolveBuildEngine` (candy/plugin-build/resolve.go) runs the ENTIRE build-engine RESOLVE plugin-side — loader, vocab, scan, connect, validate, box resolve, intermediates, order, effective versions, host-fs prep, render-seam-cache prep, render-prep, user-context, envelope projection, and drive-model — and returns `spec.BuildResolveReply` (the resolved-project envelope + a serializable drive-model: engine/platform, build order, per-image descriptors, resolved tunables; NO Containerfile content). This is the plugin-side replacement for the DELETED host `build-prep` fat seam (`charly/build_resolve_host.go`'s `hostBuildBuildResolve`). The plugin **OWNS the podman DRIVE** (the build-order loop, per-image build lock, `podman build`, push, and the inline-merge gate) IN the candy — it imports only the sdk module. It reaches the host ONLY for thin `buildengine-*` shards a sdk-only candy cannot hold: `buildengine-scan-local` (local candy scan), `buildengine-connect-plugins` (registry plugin connect), and `buildengine-prep` (the render-seam-floor `renderGenCache` — `hostBuildPrep` stores a CHEAP candy-scan-only Generator for the render-seam floor's reverse-channel consumers and returns an EMPTY map; the host-fs PREP itself moved plugin-side in the K3 host-prep move — `runHostFSPrep` in candy/plugin-build/host_prep.go). The resolved-project envelope + drive-model are NOT returned by `buildengine-prep` — they come from `resolveBuildEngine`'s reply. The render DRIVE is `sdk/deploykit.Generator`, driven by the candy over the envelope + `HostBuild("render-seam")` for the host-coupled render seams (#67 — the host no longer renders the `.build/` tree); `bake_plugin:` binary baking is INLINE via `deploykit.EmitBakedPlugins` (no `HostBuild("bake-plugins")` round-trip — that host-builder + `host_build_bake_plugins.go` are DELETED). `charly box generate` mirrors this via `build:generate` → `runBoxGenerate` → `resolveBuildEngine(..., generateOnly=true)`: `generateOnly` is a PLUGIN-SIDE parameter (NOT a `HostBuild("buildengine-prep")` argument — that host leg takes `spec.ResolvedProjectRequest`, which has no `GenerateOnly` field) that returns after the envelope projection (no engine/order drive-model); the candy renders the `.build/` tree itself via `deploykit.Generator` + returns the written Containerfile paths; no podman. (The resolved-project envelope that OTHER verbs fetch — `charly box inspect`, `charly fleet compile`, `charly check`, … — comes over the separate `InvokeProvider("build","project",OpResolve)` peer seam, the `build:project` word; the build/generate path projects its own envelope inside `resolveBuildEngine`.) See [`/charly-internals:plugin`](/recipes/internals/plugin/) (the `build` provider class + the in-proc reverse channel) and [`/charly-build:generate`](/recipes/build/generate/).
+**Build dispatch**: `charly box build` does not construct a generator host-side at all (the former `NewGenerator` is DELETED) — `BuildCmd.Run` computes the build tag, holds the build-activity retention lock, and dispatches through the COMPILED-IN `build:box` plugin (candy/plugin-build) over the F10 reverse channel; on return it runs the retention prune host-side. The plugin's `runBoxBuild` → `resolveBuildEngine` (candy/plugin-build/resolve.go) runs the ENTIRE build RESOLVE plugin-side — loader, vocab, scan, connect, validate, box resolve, intermediates, order, effective versions, host-fs prep, render-seam-cache prep, render-prep, user-context, envelope projection, and drive-model — and returns `spec.BuildResolveReply` (the resolved-project envelope + a serializable drive-model: engine/platform, build order, per-image descriptors, resolved tunables; NO Containerfile content). This is the plugin-side replacement for the DELETED host `build-prep` fat seam (`charly/build_resolve_host.go`'s `hostBuildBuildResolve`). The plugin **OWNS the BuildKit DRIVE** (the build-order loop, per-image build lock, the `buildctl` argv, the store bridge, push, and the inline-merge gate) IN the candy — it imports only the sdk module. It reaches the host ONLY for thin `buildengine-*` shards a sdk-only candy cannot hold: `buildengine-scan-local` (local candy scan), `buildengine-connect-plugins` (registry plugin connect), and `buildengine-prep` (the render-seam-floor `renderGenCache` — `hostBuildPrep` stores a CHEAP candy-scan-only Generator for the render-seam floor's reverse-channel consumers and returns an EMPTY map; the host-fs PREP itself moved plugin-side in the K3 host-prep move — `runHostFSPrep` in candy/plugin-build/host_prep.go). The resolved-project envelope + drive-model are NOT returned by `buildengine-prep` — they come from `resolveBuildEngine`'s reply. The render DRIVE is `sdk/deploykit.Generator`, driven by the candy over the envelope + `HostBuild("render-seam")` for the host-coupled render seams (#67 — the host no longer renders the `.build/` tree); `bake_plugin:` binary baking is INLINE via `deploykit.EmitBakedPlugins` (no `HostBuild("bake-plugins")` round-trip — that host-builder + `host_build_bake_plugins.go` are DELETED). `charly box generate` mirrors this via `build:generate` → `runBoxGenerate` → `resolveBuildEngine(..., generateOnly=true)`: `generateOnly` is a PLUGIN-SIDE parameter (NOT a `HostBuild("buildengine-prep")` argument — that host leg takes `spec.ResolvedProjectRequest`, which has no `GenerateOnly` field) that returns after the envelope projection (no engine/order drive-model); the candy renders the `.build/` tree itself via `deploykit.Generator` + returns the written Containerfile paths; no podman. (The resolved-project envelope that OTHER verbs fetch — `charly box inspect`, `charly check`, … — comes over the separate `InvokeProvider("build","project",OpResolve)` peer seam, the `build:project` word; the build/generate path projects its own envelope inside `resolveBuildEngine`.) See [`/charly-internals:plugin`](/recipes/internals/plugin/) (the `build` provider class + the in-proc reverse channel) and [`/charly-build:generate`](/recipes/build/generate/).
-**Build-mode emission**: the build-mode Containerfile render DRIVE is `sdk/deploykit.Generator.Generate` / `generateContainerfile` (relocated from `charly/generate.go` in #67), driven by `candy/plugin-build` over the resolved-project envelope + `HostBuild("render-seam")`; the `charly/generate.go` top-level `Generate`/`writeCandySteps` orchestrator is DELETED. the pod-overlay deploy path reaches `deploykit.Generator.EmitTasks` DIRECTLY — `charly/tasks.go` holds no shim and cannot hold one (P16 import purity forbids `charly/` importing the sdk); that path stays for the pod-overlay deploy path (the candy `plugin-deploy-pod` constructs `deploykit.OCITarget` + renders via `deploykit.NewRenderGeneratorFromProject`, the per-step fragment over the `HostBuild("step-emit","oci-emit-step")` seam); the build-mode render (in `sdk/deploykit`) drives `WriteCandySteps` → `EmitTasks` directly. The render walks each layer's ops directly to write Containerfile text — NOT the `InstallPlan` IR. It shares the package-cascade / shell-snippet / localpkg compiler helpers with the IR (one source of truth, R3), but the overall walk is the direct generator. The `InstallPlan` IR + `deploykit.OCITarget.Emit` is the DEPLOY-mode path used by `charly fleet add` (the candy `plugin-deploy-pod`'s `add_candy:` overlay synthesis, rendered in its own code via `deploykit.OCITarget` + `deploykit.NewRenderGeneratorFromProject` + the external out-of-process deploys; the local/vm/kubernetes/android substrates are external plugins via `pluginDeployTarget` (S3b, dispatched through `candy/plugin-fleet`'s `Invoke(OpDeployDispatch)`) — `deploy:local` via candy/plugin-deploy-local and `deploy:vm` via candy/plugin-deploy-vm DO consume the IR (the plugin walks it via `kit.WalkPlans` over the reverse channel, the vm one over the guest `SSHExecutor`), while `deploy:kubernetes` via candy/plugin-kube does NOT, materializing its Kustomize tree PLUGIN-side (`candy/plugin-kube/materialize.go`'s `materializeKustomize`, reaching `candy/plugin-k8sgen` peer-to-peer over `verb:k8sgen`/`OpEmit`)). See [`/charly-internals:install-plan`](/recipes/internals/install-plan/) for the IR and [`/charly-internals:generate-source`](/recipes/internals/generate-source/) for the Go call graph.
+**Build-mode emission**: the build-mode Containerfile render DRIVE is `sdk/deploykit.Generator.Generate` / `generateContainerfile` (relocated from `charly/generate.go` in #67), driven by `candy/plugin-build` over the resolved-project envelope + `HostBuild("render-seam")`; the `charly/generate.go` top-level `Generate`/`writeCandySteps` orchestrator is DELETED. the pod-overlay deploy path reaches `deploykit.Generator.EmitTasks` DIRECTLY — `charly/tasks.go` holds no shim and cannot hold one (P16 import purity forbids `charly/` importing the sdk); that path stays for the pod-overlay deploy path (the candy `plugin-deploy-pod` constructs `deploykit.OCITarget` + renders via `deploykit.NewRenderGeneratorFromProject`, the per-step fragment over the `HostBuild("step-emit","oci-emit-step")` seam); the build-mode render (in `sdk/deploykit`) drives `WriteCandySteps` → `EmitTasks` directly. The render walks each layer's ops directly to write Containerfile text — NOT the `InstallPlan` IR. It shares the package-cascade / shell-snippet / localpkg compiler helpers with the IR (one source of truth, R2), but the overall walk is the direct generator. The `InstallPlan` IR + `deploykit.OCITarget.Emit` is the DEPLOY-mode path used by `charly deploy add` (the candy `plugin-deploy-pod`'s `add_candy:` overlay synthesis, rendered in its own code via `deploykit.OCITarget` + `deploykit.NewRenderGeneratorFromProject` + the external out-of-process deploys; the local/vm/kubernetes/android substrates are external plugins via `pluginDeployTarget` (S3b, dispatched through `candy/plugin-fleet`'s `Invoke(OpDeployDispatch)`) — `deploy:local` via candy/plugin-deploy-local and `deploy:vm` via candy/plugin-deploy-vm DO consume the IR (the plugin walks it via `kit.WalkPlans` over the reverse channel, the vm one over the guest `SSHExecutor`), while `deploy:kubernetes` via candy/plugin-kube does NOT, materializing its Kustomize tree PLUGIN-side (`candy/plugin-kube/materialize.go`'s `materializeKustomize`, reaching `candy/plugin-k8sgen` peer-to-peer over `verb:k8sgen`/`OpEmit`)). See [`/charly-internals:install-plan`](/recipes/internals/install-plan/) for the IR and [`/charly-internals:generate-source`](/recipes/internals/generate-source/) for the Go call graph.
**Three-phase templates**: the embedded build vocabulary's format and builder definitions split each install operation into `phases.{prepare, install, cleanup}.{container, host}` — three phases × two venues. Build-mode emission reads the `container` cell; local deploys read `host` — the `(install, container)` cell is the single source of truth for build-mode package installation. See [`/charly-image:layer`](/recipes/image/layer/) "Service Declaration" for the analogue at the init-system level (`init..service_schema`).
@@ -44,7 +44,7 @@ charly box build --cache image [image...] # Image cache (read-only, de
charly box build --cache gha [image...] # GitHub Actions cache
charly box build --no-cache [image...] # Disable cache entirely
charly box build --jobs N [image...] # Max concurrent images per DAG level (0=auto from defaults.jobs, else 4)
-charly box build --podman-jobs N [image...] # Max concurrent stages within a single podman build (0=auto: min(NCPU, defaults.podman_jobs_cap))
+charly box build --podman-jobs N [image...] # Max concurrent stages within a single build (0=auto: min(NCPU, defaults.stage_parallelism_cap))
charly box build --include-disabled # Build an `enabled: false` image without flipping authored config
charly box build --dev-local-pkg # EVAL-BED ONLY: build localpkg candies (the charly toolchain) from LOCAL in-dev source, not the published release
```
@@ -59,7 +59,7 @@ in-development binary + plugins — a disposable check bed always tests the
in-development charly, never a stale release. Generic across all kinds + all
localpkg candies, one decision point (`deploykit.RenderLocalPkgImageInstall`); a
production box build omits the flag. A dev build that cannot build via the plugin
-HARD-errors (no silent release fallback, R4).
+HARD-errors (no silent release fallback, R3).
Full mechanics: [`/charly-internals:install-plan`](/recipes/internals/install-plan/) "Check-vs-production charly
toolchain"; the candy view: [`/charly-tools:charly`](/recipes/tools/charly/).
@@ -164,7 +164,7 @@ cat .build/my-image/Containerfile # Inspect generated output
## Build Flow
1. Run `charly box generate` internally (produces Containerfiles in `.build/`)
-2. Resolve runtime config to get build engine (`engine.build`)
+2. Resolve runtime config (there is no build-engine selection: BuildKit is the builder)
3. Resolve image build order (dependency ordering, grouped by level)
4. Filter to requested images (and their base dependencies)
5. For each level: build images in parallel (up to `--jobs` concurrent, default 4)
@@ -177,23 +177,21 @@ charly exposes **two** parallelism knobs with distinct meanings:
| Flag | Env var | `defaults:` key | What it controls |
|---|---|---|---|
| `--jobs N` | `CHARLY_BUILD_JOBS` | `jobs` | **Outer** concurrency: how many charly-level images to build in parallel within a DAG level (e.g., when `charly box build` rebuilds the whole graph). |
-| `--podman-jobs N` | `CHARLY_PODMAN_JOBS` | `podman_jobs` (+ `podman_jobs_cap`) | **Inner** concurrency: passed to `podman build --jobs N`, controls how many stages of a *single* multi-stage build run concurrently. |
+| `--podman-jobs N` | `CHARLY_PODMAN_JOBS` | `stage_parallelism` (+ `stage_parallelism_cap`) | **Inner** concurrency: how many stages of a *single* multi-stage build run concurrently. It becomes the BuildKit daemon's `[worker.oci] max-parallelism`, applied when the daemon starts. |
Both knobs are **config-driven**: precedence is **CLI flag → env → `defaults:`
in `charly.yml` → built-in fallback** (4). The inner auto value (when
-`--podman-jobs` / `CHARLY_PODMAN_JOBS` / `defaults.podman_jobs` are all unset) is
-CPU-proportional, capped at `defaults.podman_jobs_cap`: `min(NCPU, cap)`. The
-cap is the operative ceiling; the repo ships `podman_jobs_cap: 8`. (A
-high-concurrency `--cache-from` SIGABRT race in podman ≤ 5.7.x originally
-motivated a hard cap of 4 — see `CHANGELOG/` for that history and the 20-run
-race gate required before raising the cap on a new podman version.)
+`--podman-jobs` / `CHARLY_PODMAN_JOBS` / `defaults.stage_parallelism` are all unset) is
+CPU-proportional, capped at `defaults.stage_parallelism_cap`: `min(NCPU, cap)`.
+The cap is the operative ceiling, and it is the project's own to set. (The 20-run
+race gate required before raising it on a new engine version is in `CHANGELOG/`.)
```yaml
# charly.yml — defaults: block
defaults:
jobs: 4 # outer: images per DAG level
- podman_jobs: 0 # inner: 0 = auto = min(NCPU, podman_jobs_cap)
- podman_jobs_cap: 8 # ceiling for the auto calc
+ stage_parallelism: 0 # inner: 0 = auto = min(NCPU, stage_parallelism_cap)
+ stage_parallelism_cap: 8 # ceiling for the auto calc
```
```bash
@@ -202,10 +200,10 @@ CHARLY_PODMAN_JOBS=8 charly update --build # via env
charly box build --podman-jobs 1 # fully serialised, worst-case debugging
```
-Source: `candy/plugin-build/resolve.go` (`resolveDrivePodmanJobs`, feeding
-`buildkit.ResolvePodmanJobs`; the host-resolved `PodmanJobs` value feeds `--jobs`
-directly in `candy/plugin-build/podman.go`). Covered by
-`candy/plugin-build/podman_test.go`. The outer `--jobs` knob and the inner `--podman-jobs`
+Source: `candy/plugin-build/resolve.go` (`resolveDriveStageParallelism`, feeding
+`buildkit.ResolveStageParallelism`; the host-resolved `StageParallelism` value is the
+`MaxParallelism` the drive hands the daemon in `candy/plugin-build/buildkit.go`).
+Covered by `buildkit/build_helpers_test.go` (`TestResolveStageParallelism`). The outer `--jobs` knob and the inner `--podman-jobs`
are separate fields so the two semantics don't get conflated.
## Build-context excludes (`defaults.context_ignore`)
@@ -334,10 +332,8 @@ OCI labels. The CLI accepts an explicit `--tag ` for pinning;
an empty `--tag` resolves to newest-local automatically.
Rationale:
-1. **No stale-`:latest`-under-`--cache-from` bug.** The documented
- caveat where podman's `--cache-from` silently pulls a stale
- `:latest` from the remote registry is unreachable when charly never
- emits `:latest` in the first place.
+1. **Nothing can pin a stale float tag.** charly never emits `:latest`, so a build can never
+ resolve to a float tag a previous build left behind.
2. **No "ambiguous short name" errors under multiple tags.** Previously
the resolver listed every `:latest` + `:` tag of the same
image as competing candidates; the CalVer sort picks the newest
@@ -395,12 +391,11 @@ Three kinds of source changes are real cache invalidators — if you see a long
## Push Mode
-- **Docker:** `docker buildx build --push` for multi-platform builds
-- **Podman:** `podman build --manifest` + `podman manifest push`
+BuildKit's image exporter is the ONE push path — `--output type=image,name=,push=true` —
+and the only one that can push the multi-platform image a `--push` build produces. It
+covers both `docker buildx --push` and `podman manifest push`.
-Podman manifest push uses retry with exponential backoff (3 attempts, 5s/10s/20s delays) to handle transient registry errors (e.g., GHCR 500 errors after long builds).
-
-Source: `candy/plugin-build/podman.go` (`retryCmd`).
+Source: `candy/plugin-build/buildkit.go` (`pushOutput`).
## Layer Merging
@@ -447,13 +442,17 @@ For filtered builds (`charly box build `), only the built images are merg
## Engine Configuration
```bash
-charly settings set engine.build docker # or podman
-charly settings set engine.run docker # or podman
+charly settings set engine.run docker # or podman — which engine RUNS containers
```
+**There is no `engine.build`**: the image builder is BuildKit, which is not a container
+engine — it cannot run a container and therefore cannot be selected like one. `engine.run`
+is unaffected and still selectable.
+
## Host Prerequisites (First Time)
-Building images needs the `charly` binary and a container engine — `docker` or `podman`, whichever `engine.build` names. Nothing else, and no project checkout: `charly box build` runs against the `charly.yml` in the current directory, one started by `charly box new project ` or read from a published project with `--repo /`.
+Building images needs the `charly` binary and a container engine — `docker` or `podman`, whichever `engine.run` names (BuildKit builds the image; the drive bridges it into
+that engine's store). Nothing else, and no project checkout: `charly box build` runs against the `charly.yml` in the current directory, one started by `charly box new project ` or read from a published project with `--repo /`.
Install `charly` as a distro-native package with your OWN package manager (`pacman -U` / `dnf install` / `apt install` / `apk add`) — the `charly generate-packages` plugin (nFPM) builds the `.pkg.tar.zst`/`.rpm`/`.deb`/`.apk`/`.ipk` published to the per-distro package repos. Obtaining the binary is covered end to end at [Install](https://opencharly.ai/start/install/); it is the one page that describes building from a source tree.
@@ -516,9 +515,13 @@ Beyond the YAML-unmarshal symptom above, a stale `charly` binary can produce *sy
If the symptom persists after upgrading, the copy on `$PATH` may not be the one you just installed — `which charly` reports which wins.
-### Buildah cache-mount corruption (pixi tzdata et al.)
+### Cache-mount corruption (pixi tzdata et al.)
-Cache mounts declared via `--mount=type=cache,dst=,uid=,gid=` (used for pixi, npm, cargo, rattler, dnf) persist across builds and are **not** evicted by `charly box build --no-cache` (which only suppresses `--cache-from`; see above). A disk-full event mid-download can leave a partial package in the cache, and every subsequent build re-hits the same broken file:
+Cache mounts are declared as `--mount=type=cache,id=charly-,dst=,sharing=locked` (pixi, npm, cargo,
+rattler and dnf all use one — `buildkit.SharedCacheMount` emits the format). Their **id is stable on purpose**:
+that is what makes the cache survive layer-hash churn, which is the entire reason cache mounts exist
+(`candy/plugin-build/cache_mount_test.go`). A cache mount is therefore not the build cache: `charly box build
+--no-cache` disables the build cache for that build, and the mount's contents persist regardless. A disk-full event mid-download can leave a partial package in the cache, and every subsequent build re-hits the same broken file:
```
error: × failed to link tzdata-2025c-hc9c84f9_1.conda
@@ -535,71 +538,21 @@ Options, in order of least to most disruptive:
`podman image prune -a -f` will delete tagged images that are **not referenced by a running container**. That means the image you just built via `charly box build` — and haven't started as a container yet — is eligible for prune. Observed sequence: `charly box build` → `podman image prune -af` → `sudo podman load` (manual rootful refresh) fails with `image not known`. Protect the fresh build by starting a container or use `prune` with explicit filters instead.
-### `--no-cache` does not invalidate intermediate scratch-stage caches
-
-`charly box build --no-cache ` and `charly box build --cache none ` reliably disable the
-cache for the **final image stage**, but in observed behavior they do **not** propagate
-to intermediate scratch stages produced by `COPY candy// /` instructions
-(`[15/25] STEP 2/2: COPY candy/labwc/ /` style). Editing a single file inside a layer
-directory and rebuilding with `--no-cache` may still pull the labwc scratch stage from
-cache, leaving the new file content out of the rebuilt image.
-
-**Workaround:** force a content-hash bump on the layer's `charly.yml`. The simplest is
-adding (or removing) a trailing comment line:
-
-```bash
-echo "" >> candy/labwc/charly.yml # bump content hash
-charly box build selkies-desktop # now invalidates the labwc scratch stage
-git checkout -- candy/labwc/charly.yml # revert the cosmetic change
-```
-
-This was discovered while shipping commit `febb9bd` (labwc autostart race fix): the
-edit to `candy/labwc/autostart` did not propagate to the rebuilt image until
-`candy/labwc/charly.yml` itself was touched. Two consecutive `--no-cache` rebuilds
-produced the same image hash (`502c8012c7a5`) until the charly.yml content changed.
-
-Symptom: `charly box list tags ` shows a new tag, but `charly shell -c "cat /path/to/changed/file"`
-returns the **old** content.
-
-### Known caveat: stale `:latest` under `--cache-from`
-
-charly's Containerfile generator currently emits `FROM /:latest`
-for parent builder images (not pinned CalVer). When charly invokes
-`podman build --cache-from /`, podman resolves those `FROM`
-clauses at parse time and will **pull `:latest` from the remote registry**,
-silently clobbering any locally-rebuilt `:latest` tag. For images that rebuild
-their builder stages in the same invocation this can lead to the later
-stages using the **stale registry builder** instead of the freshly-built one.
+### Forcing a rebuild of one layer's scratch stage
-**Symptom observed on this project:** `charly update selkies-desktop --build`
-fails deep inside selkies' `pixi install && bash build.sh` step with
-`error: can't find Rust compiler`. The remote
-`ghcr.io/opencharly/fedora-builder:latest` predates the `build-toolchain`
-layer adding `cargo` as an RPM, so its pixi env has no rustc — even though
-the current local `build-toolchain/charly.yml` lists `cargo`. The build phase
-that rebuilds fedora-builder locally *does* run, but parent-stage FROM
-resolution happens before that stage exists, and podman pulls the stale
-remote image.
-
-**Workaround until the generator is fixed:**
+A layer's scratch stage is keyed by the layer directory's own content — the cache key is
+`(parent-image-SHA, instruction-text, COPY-source-content)` (see "Cache Efficiency" above) — so editing any
+file under `candy//` rebuilds that stage and everything downstream of it. To force that rebuild without
+changing the file that matters, bump the layer's `charly.yml`:
```bash
-charly box build --cache=none # or equivalently --no-cache at the charly level
+echo "" >> candy/labwc/charly.yml # bump the layer's content hash
+charly box build selkies-desktop # rebuilds the labwc scratch stage
+git diff candy/labwc/charly.yml # the appended line, and nothing else
```
-Both `--cache=none` and `--no-cache` short-circuit `cacheArgs()` in
-`candy/plugin-build/podman.go` (`driveConfig.cacheArgs`) and do NOT pass `--cache-from` to podman, so
-the broken resolution path never fires. `--no-cache` is charly-level only — it
-does *not* pass `--no-cache` to podman, it just skips `--cache-from`.
-
-**Proper fix (not yet implemented):** charly's generator should emit pinned
-CalVer tags for builder `FROM` clauses (e.g.,
-`FROM ghcr.io/opencharly/fedora-builder:2026.105.0128`) or pass
-`--pull=never` to podman so local tags aren't resolved from the remote.
-Tracked as a follow-up.
-
-See also: [`/charly-build:generate`](/recipes/build/generate/) for the Containerfile generation path, [`/charly-core:charly-update`](/recipes/core/charly-update/)
-for the `--build` flag that also picks up this caveat.
+Remove the added line to revert it. Do not reach for `git checkout -- ` to undo it: that restores the
+file from the index and discards any other uncommitted work it holds.
## Project directory override
diff --git a/src/content/docs/recipes/build/docs.md b/src/content/docs/recipes/build/docs.md
index f98c3f19..33f66bac 100644
--- a/src/content/docs/recipes/build/docs.md
+++ b/src/content/docs/recipes/build/docs.md
@@ -13,6 +13,35 @@ description: "The `charly docs generate` verb — the generator rendering the re
**opencharly.ai** from the sources that already exist in this repository. It is served by
`candy/plugin-docs`.
+## R4 — documentation never routes around a defect
+
+The rulebook states R4 as a mandate (umbrella `AGENTS.md`, "The ground-truth rules
+R1–R10"); this is its operationalization for every page this skill renders and every
+hand-authored page beside it.
+
+- **Fix the product first.** When documented behaviour and the code disagree, establish
+ which side holds the INTENT, then fix the code BEFORE touching the prose. Editing docs
+ to match a bug is forbidden; so is editing them to avoid one.
+- **The tells of a routed-around defect:** a `git clone`, a `cd`, or a `-C` standing in
+ for a flag that should work; an "except on…" caveat; a "known limitation" note in place
+ of a fix. Each describes the defect instead of removing it — and makes it permanent by
+ making it look intended.
+- **Binary-only reader.** Every command a reader is told to run MUST work with nothing but
+ the `charly` binary installed — no clone, no `cd`, no pre-existing project, no unnamed
+ companion tool. Write every page for a reader who installed `charly` as a native package
+ with NO charly checkout on the machine: no `task` target, no `./bin/charly`, no
+ repo-relative path, no verb supplied by a candy that exists only in this repository.
+ `--repo /` reads a published project without a clone;
+ `charly box new project ` starts a local one.
+- **The one exception is the INSTALL page**, where the reader obtains charly: it leads
+ with the native package and scopes the checkout to working ON charly. A
+ repository-maintenance command is named as maintenance this project performs, never as
+ a step the reader runs.
+- **Anywhere else, needing more than the binary is a PRODUCT DEFECT** — the missing
+ capability is the bug, and the fix belongs in `charly`. If that fix is genuinely out of
+ scope, file it with its root cause and let the page say nothing rather than something
+ false: silence is recoverable, a documented workaround is taught.
+
## Placement: a RUNTIME plugin, deliberately
`plugin-docs` is **not** listed in `charly/charly.yml`'s `compiled_plugins:`. A dev-time
@@ -53,8 +82,8 @@ charly docs generate \
## What it emits
-The generator owns six top-level entries — `index.md`, `grievances.md`, `vision.md`,
-`liberation.md`, `reference/` and `recipes/` — and rewrites them wholesale each run, so a deleted
+The generator owns seven top-level entries — `index.md`, `grievances.md`, `vision.md`,
+`liberation.md`, `soul.md`, `reference/` and `recipes/` — and rewrites them wholesale each run, so a deleted
source entity disappears from the site instead of lingering as an orphan page. (The table below
expands `reference/` into its sub-trees plus the single file `reference/providers.md`, so it
runs to many rows.) What stays hand-authored is the teaching narrative this repository has no
@@ -79,6 +108,7 @@ not the page.**
| `grievances.md` | `GRIEVANCES.md`, H1 dropped, repo-relative links rewritten for a web reader |
| `vision.md` | `VISION.md`, H1 dropped, repo-relative links rewritten for a web reader |
| `liberation.md` | `LIBERATION.md`, H1 dropped, repo-relative links rewritten — the deliberate easter-egg page, entered last in the sidebar as "Liberation" |
+| `soul.md` | `SOUL.md`, H1 dropped, published verbatim — the identity of charly and every charly agent; self-contained, so there are no repo-relative links to rewrite |
| `reference/cli/` | one page per `command:` provider word |
| `reference/candy/` | every defined candy: packages, services, and its `plan:` as an acceptance spec |
| `reference/box/` | every defined box |
@@ -175,7 +205,7 @@ the pinned charly) belongs to the same cutover as the candy edit.
`candy/*/charly.yml` — but the generator DOES project candy `skill:` entities directly
from its own remote-aware walk (`collectCandySkills`), the complement that keeps a moved
candy's recipe references resolving on the site even before the marketplace regeneration
-lands (the stale-marketplace gap [`/charly-tools:crabbox`](/recipes/tools/crabbox/) measures — see the tests
+lands (the stale-marketplace gap [`/charly-tools:crabbox-cli`](/recipes/tools/crabbox-cli/) measures — see the tests
section). The long path still holds: a skill is authored as a `skill:` entity in its
owning candy; `charly marketplace generate` projects that into the
opencharly/marketplace repo (`/skills//SKILL.md`), and
@@ -207,7 +237,7 @@ stylistic preference; the obvious surfaces are actively wrong for the purpose:
- A plugin absent from `compiled_plugins:` still loads out-of-process when a plan references its
word, so "in the binary" is not the same set as "exists".
-So discovery is not the walked trees alone: the catalog is ONE assembly (R3/R5,
+So discovery is not the walked trees alone: the catalog is ONE assembly (R2/R5,
`assembleCatalog`) unioning three sets —
1. the **walked closure** — remote-ref-aware (`candywalk.CollectEntitiesRemote`):
@@ -295,7 +325,7 @@ describe the bad shape rather than spelling it.
The host renders every dynamic command word with a generic stub description, intercepts the
depth-1 `--help` itself, and plugin-served help arrives in at least three mutually incompatible
-formats with no machine-readable dump. Parsing that would be the fragile shim R4 forbids. Every
+formats with no machine-readable dump. Parsing that would be the fragile shim R3 forbids. Every
fact the site needs is already declared: `plugin.providers`, the per-plugin `schema/*.cue`, and
each candy's `description:`.
diff --git a/src/content/docs/recipes/build/generate.md b/src/content/docs/recipes/build/generate.md
index 40e72fdc..f3c48707 100644
--- a/src/content/docs/recipes/build/generate.md
+++ b/src/content/docs/recipes/build/generate.md
@@ -17,13 +17,13 @@ Parses `charly.yml`, scans `candy/`, resolves the dependency graph, and emits al
The generator is **config-driven** — distro format templates, builder stage templates, and init system fragments all come from the build vocabulary embedded in the `charly` binary at `charly/charly.yml` (three top-level sections: `distro:`, `builder:`, `init:`) — and **declarative per-task** for install logic — each task verb (see [`/charly-image:layer`](/recipes/image/layer/)) has a dedicated emitter that writes the right Containerfile directive.
-**Build-engine dispatch**: `charly box generate` does not construct a generator host-side at all (the former `NewGenerator` is DELETED) — it is served by the COMPILED-IN `command:box` plugin (candy/plugin-box, nested under the `box` grammar group), whose generate handler invokes the peer `build:generate` plugin (candy/plugin-build) over the F10 reverse channel (InvokeProvider). The plugin's `runBoxGenerate` → `resolveBuildEngine(..., generateOnly=true)` (candy/plugin-build/resolve.go) runs the ENTIRE build-engine RESOLVE plugin-side — loader, vocab, scan, connect, validate, box resolve, intermediates, order, effective versions, host-fs prep, render-seam-cache prep, render-prep, user-context, and envelope projection — and returns the resolved-project envelope (`spec.BuildResolveReply.ResolvedProject`); `generateOnly` is a PLUGIN-SIDE parameter that returns after the envelope projection (no engine/order drive-model). The host is reached ONLY for thin `buildengine-*` shards (`buildengine-scan-local`, `buildengine-connect-plugins`, and `buildengine-prep` — the render-seam-floor `renderGenCache` populate, which returns an EMPTY map; the host-fs PREP itself moved plugin-side); the envelope + drive-model come from `resolveBuildEngine`'s reply, NOT from `HostBuild("buildengine-prep")` (that host leg takes `spec.ResolvedProjectRequest`, which has no `GenerateOnly` field). The candy renders the Containerfiles itself via `sdk/deploykit.Generator` over the envelope + `HostBuild("render-seam")` (#67 — the host no longer renders); the written paths are returned — no podman (generate builds nothing). `charly box build` uses the SAME resolve pipeline (`runBoxBuild` → `resolveBuildEngine(..., generateOnly=false)`, which additionally computes the drive-model), then the candy additionally OWNS the podman DRIVE (`podman build` / push / inline-merge) IN the candy. See [`/charly-internals:plugin`](/recipes/internals/plugin/) (the `build` provider class + the in-proc reverse channel) and [`/charly-build:build`](/recipes/build/build/).
+**Build dispatch**: `charly box generate` does not construct a generator host-side at all (the former `NewGenerator` is DELETED) — it is served by the COMPILED-IN `command:box` plugin (candy/plugin-box, nested under the `box` grammar group), whose generate handler invokes the peer `build:generate` plugin (candy/plugin-build) over the F10 reverse channel (InvokeProvider). The plugin's `runBoxGenerate` → `resolveBuildEngine(..., generateOnly=true)` (candy/plugin-build/resolve.go) runs the ENTIRE build RESOLVE plugin-side — loader, vocab, scan, connect, validate, box resolve, intermediates, order, effective versions, host-fs prep, render-seam-cache prep, render-prep, user-context, and envelope projection — and returns the resolved-project envelope (`spec.BuildResolveReply.ResolvedProject`); `generateOnly` is a PLUGIN-SIDE parameter that returns after the envelope projection (no engine/order drive-model). The host is reached ONLY for thin `buildengine-*` shards (`buildengine-scan-local`, `buildengine-connect-plugins`, and `buildengine-prep` — the render-seam-floor `renderGenCache` populate, which returns an EMPTY map; the host-fs PREP itself moved plugin-side); the envelope + drive-model come from `resolveBuildEngine`'s reply, NOT from `HostBuild("buildengine-prep")` (that host leg takes `spec.ResolvedProjectRequest`, which has no `GenerateOnly` field). The candy renders the Containerfiles itself via `sdk/deploykit.Generator` over the envelope + `HostBuild("render-seam")` (#67 — the host no longer renders); the written paths are returned — no podman (generate builds nothing). `charly box build` uses the SAME resolve pipeline (`runBoxBuild` → `resolveBuildEngine(..., generateOnly=false)`, which additionally computes the drive-model), then the candy additionally OWNS the BuildKit DRIVE (the `buildctl` argv / push / inline-merge) IN the candy. See [`/charly-internals:plugin`](/recipes/internals/plugin/) (the `build` provider class + the in-proc reverse channel) and [`/charly-build:build`](/recipes/build/build/).
-**Build-mode emission is `WriteCandySteps` → `EmitTasks`** in `sdk/deploykit` (`deploykit.Generator`, relocated from `charly/generate.go` in #67, driven by `candy/plugin-build` over the resolved-project envelope + `HostBuild("render-seam")`; the `charly/generate.go` top-level `Generate`/`writeCandySteps` orchestrator is DELETED). the pod-overlay deploy path reaches `deploykit.Generator.EmitTasks` DIRECTLY — `charly/tasks.go` holds no shim and cannot hold one (P16 import purity forbids `charly/` importing the sdk); that path stays for the pod-overlay deploy path (the candy `plugin-deploy-pod` constructs `deploykit.OCITarget` + renders via `deploykit.NewRenderGeneratorFromProject`, the per-step fragment over the `HostBuild("step-emit","oci-emit-step")` seam). The render walks each layer's ops directly to write Containerfile text — NOT the `InstallPlan` IR. Build shares the package-cascade, shell-snippet, and localpkg compiler helpers with the IR (`ResolveCascadePackages`, `CompileShellSnippetSteps`, `deploykit.RenderLocalPkgImageInstall` — one source of truth, R3, relocated to sdk/deploykit in W3), but the overall walk is the direct generator, not an `EmitTarget`. The `InstallPlan` IR + `deploykit.OCITarget.Emit` is the DEPLOY-mode path: the candy `plugin-deploy-pod`'s overlay-Containerfile synthesis (rendered in its own code via `deploykit.OCITarget` + `deploykit.NewRenderGeneratorFromProject`) when `add_candy:` is set, plus the external execution targets. (The local/vm/kubernetes/android substrates are external out-of-process plugins via `pluginDeployTarget` (S3b, dispatched through `candy/plugin-fleet`'s `Invoke(OpDeployDispatch)`): `deploy:local` (candy/plugin-deploy-local) and `deploy:vm` (candy/plugin-deploy-vm) DO consume the IR — the plugin walks it via `kit.WalkPlans` over the reverse channel, the vm one over the guest `SSHExecutor` so the walk runs inside the guest — while `deploy:kubernetes` (candy/plugin-kube) does NOT, materializing its Kustomize tree PLUGIN-side (`candy/plugin-kube/materialize.go`'s `materializeKustomize`, reaching `candy/plugin-k8sgen` peer-to-peer over `verb:k8sgen`/`OpEmit`).) See [`/charly-internals:install-plan`](/recipes/internals/install-plan/) for the IR catalog and [`/charly-internals:generate-source`](/recipes/internals/generate-source/) for the Go-level call graph.
+**Build-mode emission is `WriteCandySteps` → `EmitTasks`** in `sdk/deploykit` (`deploykit.Generator`, relocated from `charly/generate.go` in #67, driven by `candy/plugin-build` over the resolved-project envelope + `HostBuild("render-seam")`; the `charly/generate.go` top-level `Generate`/`writeCandySteps` orchestrator is DELETED). the pod-overlay deploy path reaches `deploykit.Generator.EmitTasks` DIRECTLY — `charly/tasks.go` holds no shim and cannot hold one (P16 import purity forbids `charly/` importing the sdk); that path stays for the pod-overlay deploy path (the candy `plugin-deploy-pod` constructs `deploykit.OCITarget` + renders via `deploykit.NewRenderGeneratorFromProject`, the per-step fragment over the `HostBuild("step-emit","oci-emit-step")` seam). The render walks each layer's ops directly to write Containerfile text — NOT the `InstallPlan` IR. Build shares the package-cascade, shell-snippet, and localpkg compiler helpers with the IR (`ResolveCascadePackages`, `CompileShellSnippetSteps`, `deploykit.RenderLocalPkgImageInstall` — one source of truth, R2, relocated to sdk/deploykit in W3), but the overall walk is the direct generator, not an `EmitTarget`. The `InstallPlan` IR + `deploykit.OCITarget.Emit` is the DEPLOY-mode path: the candy `plugin-deploy-pod`'s overlay-Containerfile synthesis (rendered in its own code via `deploykit.OCITarget` + `deploykit.NewRenderGeneratorFromProject`) when `add_candy:` is set, plus the external execution targets. (The local/vm/kubernetes/android substrates are external out-of-process plugins via `pluginDeployTarget` (S3b, dispatched through `candy/plugin-fleet`'s `Invoke(OpDeployDispatch)`): `deploy:local` (candy/plugin-deploy-local) and `deploy:vm` (candy/plugin-deploy-vm) DO consume the IR — the plugin walks it via `kit.WalkPlans` over the reverse channel, the vm one over the guest `SSHExecutor` so the walk runs inside the guest — while `deploy:kubernetes` (candy/plugin-kube) does NOT, materializing its Kustomize tree PLUGIN-side (`candy/plugin-kube/materialize.go`'s `materializeKustomize`, reaching `candy/plugin-k8sgen` peer-to-peer over `verb:k8sgen`/`OpEmit`).) See [`/charly-internals:install-plan`](/recipes/internals/install-plan/) for the IR catalog and [`/charly-internals:generate-source`](/recipes/internals/generate-source/) for the Go-level call graph.
**Build-time plugin execution**: the build path connects the project's external (out-of-tree) plugin candies via `loadProjectPlugins` (`charly/host_build_buildengine.go:51`, and `charly/check_cmd.go:70` for the check path), so a `run:` plugin verb — and a plugin builder — is registered + dialable DURING image generation, the SAME loader the deploy/check paths use. The host-side `NewGenerator` that used to perform this connect is DELETED. A builtin is already registered via `init()` and needs no connect; only an external one is host-built + connected here. `EmitTasks` then dispatches a `plugin:` verb step PLACEMENT-AGNOSTICALLY above the registry: a builtin `ProvisionActor` renders an act shell `RUN` in-proc (the zero-JSON fast path), while ANY other resolved provider — an external `grpcProvider`, or a builtin emitting a richer fragment — renders via `invokeOpEmitFragmentOpt` → `Invoke(OpEmit)` (in-proc for a builtin, go-plugin gRPC for an external), and the returned `spec.EmitReply.Fragment` is spliced verbatim into the Containerfile (egress-validated with the rest before write). This is operator-authorized build-time execution of host-built plugin code — a project's composed external plugins run as host code during its image builds. Detail → [`/charly-internals:plugin`](/recipes/internals/plugin/) (placement) + [`/charly-internals:generate-source`](/recipes/internals/generate-source/).
-**Build-time BUILDER leg (all via `OpResolve`)**: the builder stage emission is `deploykit.Generator.EmitBuilderStages` / `EmitBuilderArtifacts` / `EmitExternalBuilderStages` / `EmitExternalBuilderArtifacts` (`sdk/deploykit/builders_render.go` — relocated from `charly/generate.go` in #67, driven by `candy/plugin-build` over the envelope + `HostBuild("render-seam")`); the shared builder `OpResolve` helper (`renderSeamCaller.resolveBuilderStage`, `sdk/deploykit/render_generator_from_project.go`) drives both legs (the former `charly/generate.go` is DELETED, K-wave 2). These render the four DETECTION-builders' stages (pixi/npm/aur — selected by a candy's `pixi.toml` / `package.json` / `aur:` section; cargo is INLINE) by `Invoke(OpResolve)`ing their plugins — NOT from an in-core `builders.` vocabulary (C10 moved the stage templates into the plugins' `kit.BuilderResolve`). For each detected `(candy, builder)`, `EmitBuilderStages` connects the plugin on-demand (`ensureBuildersConnected`) and calls the shared `resolveBuilderStage` → `prov.Invoke(OpResolve)` with a `spec.BuilderResolveInput` (the host-computed render context — builder ref, stage name, copy src, uid/gid/home, detected manifest/lockfile/build-script, aur packages/options, PRE-RENDERED cache-mount flags) as `op.Params` + a `spec.BuildEnv` as `op.Env`; the returned `spec.BuilderResolveReply.Stage` is written verbatim PRE-main-FROM (cached per `(candy, builder)`), `EmitBuilderArtifacts` writes the cached `CopyArtifacts` + once-per-builder `CopyBinary` POST-main-FROM, and cargo's `InlineFragment` splices in `WriteCandySteps` (deploykit). A candy that instead selects an OUT-OF-TREE builder via its `external_builder: ` field gets its multi-stage emitted by `EmitExternalBuilderStages` / `EmitExternalBuilderArtifacts` (run right after their detection counterparts), sharing the SAME `resolveBuilderStage` but sending a MINIMAL input (candy name only): the word resolves through `providerRegistry.ResolveBuilder` to an EXTERNAL `*grpcProvider`, and `EmitExternalBuilderStages` (the BUILDER-leg analogue of `invokeOpEmitFragmentOpt`) requires a non-empty `Stage`. An empty `Stage` (or empty cargo `InlineFragment`), an unresolvable word, or an Invoke error fails LOUDLY (R4) — never a silently-dropped builder stage. Every half is egress-validated with the rest of the Containerfile before write. Detail → [`/charly-internals:plugin`](/recipes/internals/plugin/) (placement) + [`/charly-internals:generate-source`](/recipes/internals/generate-source/).
+**Build-time BUILDER leg (all via `OpResolve`)**: the builder stage emission is `deploykit.Generator.EmitBuilderStages` / `EmitBuilderArtifacts` / `EmitExternalBuilderStages` / `EmitExternalBuilderArtifacts` (`sdk/deploykit/builders_render.go` — relocated from `charly/generate.go` in #67, driven by `candy/plugin-build` over the envelope + `HostBuild("render-seam")`); the shared builder `OpResolve` helper (`renderSeamCaller.resolveBuilderStage`, `sdk/deploykit/render_generator_from_project.go`) drives both legs (the former `charly/generate.go` is DELETED, K-wave 2). These render the four DETECTION-builders' stages (pixi/npm/aur — selected by a candy's `pixi.toml` / `package.json` / `aur:` section; cargo is INLINE) by `Invoke(OpResolve)`ing their plugins — NOT from an in-core `builders.` vocabulary (C10 moved the stage templates into the plugins' `kit.BuilderResolve`). For each detected `(candy, builder)`, `EmitBuilderStages` connects the plugin on-demand (`ensureBuildersConnected`) and calls the shared `resolveBuilderStage` → `prov.Invoke(OpResolve)` with a `spec.BuilderResolveInput` (the host-computed render context — builder ref, stage name, copy src, uid/gid/home, detected manifest/lockfile/build-script, aur packages/options, PRE-RENDERED cache-mount flags) as `op.Params` + a `spec.BuildEnv` as `op.Env`; the returned `spec.BuilderResolveReply.Stage` is written verbatim PRE-main-FROM (cached per `(candy, builder)`), `EmitBuilderArtifacts` writes the cached `CopyArtifacts` + once-per-builder `CopyBinary` POST-main-FROM, and cargo's `InlineFragment` splices in `WriteCandySteps` (deploykit). A candy that instead selects an OUT-OF-TREE builder via its `external_builder: ` field gets its multi-stage emitted by `EmitExternalBuilderStages` / `EmitExternalBuilderArtifacts` (run right after their detection counterparts), sharing the SAME `resolveBuilderStage` but sending a MINIMAL input (candy name only): the word resolves through `providerRegistry.ResolveBuilder` to an EXTERNAL `*grpcProvider`, and `EmitExternalBuilderStages` (the BUILDER-leg analogue of `invokeOpEmitFragmentOpt`) requires a non-empty `Stage`. An empty `Stage` (or empty cargo `InlineFragment`), an unresolvable word, or an Invoke error fails LOUDLY (R3) — never a silently-dropped builder stage. Every half is egress-validated with the rest of the Containerfile before write. Detail → [`/charly-internals:plugin`](/recipes/internals/plugin/) (placement) + [`/charly-internals:generate-source`](/recipes/internals/generate-source/).
**Three-phase templates**: the embedded build vocabulary's format (`formats.`) and builder (`builders.`) definitions carry a `phases: { prepare, install, cleanup }.{ container, host }` structure. The generator reads `phases.install.container` as the single source for build-mode package installation. The `host:` cell is consumed only when a `SystemPackagesStep` renders on a host venue (`RenderHostPackageCommand`) — for the external `local:` AND `vm:` deploys the plugin drives the `SystemPackages` host render via the host's `RunHostStep` channel — never by the generator.
diff --git a/src/content/docs/recipes/build/migrate.md b/src/content/docs/recipes/build/migrate.md
index a5ed46aa..0967cb33 100644
--- a/src/content/docs/recipes/build/migrate.md
+++ b/src/content/docs/recipes/build/migrate.md
@@ -1,124 +1,119 @@
---
title: "migrate"
-description: "The `charly migrate` command — the single idempotent migration bringing any opencharly config to the latest schema CalVer."
+description: "The `charly migrate` command — the single idempotent reshape pass bringing any opencharly config forward to the current schema."
---
*Recipe card from the `charly-build` plugin (Commands — runtime CLI verbs).*
-# charly migrate — single-command schema migration
+# charly migrate — the version-free reshape pass
-`charly migrate` is **one idempotent command** that brings any opencharly config — as far back as the supported schema floor — up to the **latest schema CalVer**, and only to the latest. There are no sub-verbs to choose between: `charly migrate` always applies every migration-table step newer than the config, up to HEAD.
+`charly migrate` is **one idempotent command** that applies the declarative migration table to an opencharly config on disk. There are no sub-verbs to choose between: it walks the whole table in declaration order, and because every step is idempotent a re-run on an already-current project is a no-op.
```bash
-charly migrate # migrate every reachable config to the latest schema CalVer
-charly migrate --dry-run # print every change migrate would make; touch nothing
+charly migrate # apply the table to every reachable config
+charly migrate --dry-run # print every change migrate would make; touch nothing
+charly migrate --project-only # skip the per-host overlay
```
The project directory is the current working directory; use the top-level `-C` / `--dir` / `CHARLY_PROJECT_DIR` global to point elsewhere (`main()` chdir's before dispatch).
-## CalVer schema versioning
+## There is no schema version
-The YAML schema version is a **CalVer string** — `version: YYYY.DDD.HHMM`, the same fixed-width scheme as image tags (e.g. a HEAD like `version: 2026.174.1100`). It is CANONICAL fixed-width: a 4-digit year, a 3-digit zero-padded day-of-year, and a 4-digit zero-padded HHMM, so a plain alphanumeric sort of CalVer strings is chronological. `ParseCalVer` is EXTREMELY STRICT — it accepts ONLY that exact form (no `version: 4`, no non-padded `2026.45.830`); a non-canonical value is "not a CalVer", which the load gate treats as older-than-HEAD so it flows into `charly migrate`. `charly migrate` re-stamps every versioned file it brings forward to the canonical HEAD (`universalStamp`); a value that predates the supported floor — or is not a CalVer at all — is unmigratable and refused untouched (see "How it runs"). Every versioned file carries the stamp:
+A config carries **no `version:` field**, and the schema is CLOSED: a `charly.yml` still carrying a retired key — `version:` above all — is an unknown field and **fails closed-CUE when it loads**. That load failure is the trigger to run `charly migrate`, and `charly migrate` is what removes the retired keys.
-- every project `charly.yml` (splitting into per-kind `vm.yml` / `local.yml` / … siblings is an optional `import:` convenience a project MAY use — never the default; see [`/charly-image:image`](/recipes/image/image/))
-- the per-host `~/.config/charly/charly.yml`
+There is no schema HEAD, no floor, and no version comparison anywhere in the engine. `charly migrate` does not decide *whether* a project needs migrating — it reshapes every project it is pointed at, and idempotency is what makes that safe. A universal final step, **`strip-version-stamp`**, removes a leftover top-level or per-entity `version:` key: from the root `charly.yml`, from every other project document, and — unless `--project-only` — from the per-host overlay.
-The parsed `ParsedCalVer` type, `ParseCalVer(string) (ParsedCalVer, bool)`, `ParsedCalVer.Less`, and `MustCalVer` live in **`spec/spec/calver_parse.go`** (the parsed type is named `ParsedCalVer` because `spec` already binds `CalVer = string`, the CUE wire scalar); `sdk/kit/calver.go` re-exports them for kit consumers. **CUE owns the HEAD and floor:** `spec/schema/version.cue` defines `#SchemaVersion` (the HEAD CalVer every file is stamped to) and `#SchemaFloor` (the oldest version migratable FROM). The spec repo's `charly task cue-gen` emits `spec/spec/version_gen.go` with the `SchemaVersion` / `SchemaFloor` string consts; `spec.LatestSchemaCalVer()` and `spec.SchemaFloorCalVer()` parse them, re-exported as `kit.LatestSchemaVersion()` / `kit.SchemaFloor()` (what `candy/plugin-migrate/engine.go` calls) and shimmed in core by `charly/version.go`'s `LatestSchemaVersion()`. There is NO hand-maintained Go HEAD literal — the load-time gate requires `kit.LatestSchemaVersion()`, and `charly migrate` refuses anything below `kit.SchemaFloor()`.
+### Per-merge release git tags
-### Per-merge release git tags (decoupled from `version:`)
-
-Every landing of ANY repo carries a **fresh** annotated release git tag `v` — ONE per merge, so a repo accumulates MULTIPLE `v…` tags over time. The tag is **generated and applied by the fresh `pr-validator` at MERGE**, from the current UTC merge time — never by the author at push time (author-time stamps collide and mis-order across concurrent out-of-sync PRs; see [`/charly-internals:git-workflow`](/recipes/internals/git-workflow/) "CalVer"). This is **decoupled** from the `charly.yml` `version:` field: `version:` is the SCHEMA version (bumped only when a cutover raises `#SchemaVersion`), whereas the tag marks the merge moment. Tag EVERY landing — including one at an unchanged `version:` (a content change, submodule extraction, image drop). Tags are **immutable**: only ever ADD new ones, never move or force-push an existing tag (so the load-time gate never sees a "newer than supported" CalVer from a re-tag). Every component is fixed-width zero-padded — 4-digit year, 3-digit day-of-year, 4-digit HHMM (`v2026.064.1937`, `v2026.142.1640`) — so tags sort chronologically under a plain alphanumeric sort; the evaluator computes `v$(date -u +%Y.%j.%H%M)` at merge (`%j` and `%H%M` are already zero-padded). Each repo — the superproject, every `box/` submodule, `plugins`, and `docs` — is tagged independently on its merged `main` HEAD (the tag marks the merge, so a repo needs no `charly.yml` to be tagged); a producer repo's PR merges + tags BEFORE its consumer's pointer bump. The SOLE exception is `sdk`, which tags under its Go-module `v0..` scheme (a semver requirement, not an exemption). See the project rulebook "Post-Execution Policies" (`AGENTS.md` / `CLAUDE.md`) and [`/charly-internals:git-workflow`](/recipes/internals/git-workflow/).
+Every landing of ANY repo carries a **fresh** annotated release git tag `v` — ONE per merge, so a repo accumulates MULTIPLE `v…` tags over time. The tag is **generated and applied by the fresh `pr-validator` at MERGE**, from the current UTC merge time — never by the author at push time (author-time stamps collide and mis-order across concurrent out-of-sync PRs; see [`/charly-internals:git-workflow`](/recipes/internals/git-workflow/) "CalVer"). Tag EVERY landing — a content change, a submodule extraction, an image drop. Tags are **immutable**: only ever ADD new ones, never move or force-push an existing tag. Every component is fixed-width zero-padded — 4-digit year, 3-digit day-of-year, 4-digit HHMM (`v2026.064.1937`, `v2026.142.1640`) — so tags sort chronologically under a plain alphanumeric sort; the evaluator computes `v$(date -u +%Y.%j.%H%M)` at merge (`%j` and `%H%M` are already zero-padded). Each repo — the superproject, every `box/` submodule, `plugins`, and `docs` — is tagged independently on its merged `main` HEAD (the tag marks the merge, so a repo needs no `charly.yml` to be tagged); a producer repo's PR merges + tags BEFORE its consumer's pointer bump. The SOLE exception is `sdk`, which tags under its Go-module `v0..` scheme (a semver requirement, not an exemption). The tag is independent of the migration table: the tag marks the MERGE, a migration entry marks the SCHEMA. See the project rulebook "Commit, push, land" (`AGENTS.md`) and [`/charly-internals:git-workflow`](/recipes/internals/git-workflow/).
## The migration table (declarative data)
-The migration steps are **declarative DATA, not code**: an ordered list embedded from **`candy/plugin-migrate/migrations.cue`** (the TABLE lives in candy/plugin-migrate), validated at process start against the `#Migration` schema (`candy/plugin-migrate/schema/migration.cue` — a plugin-only validation schema that lives in the plugin per the kernel/plugin boundary law, concatenated at compile with the SDK's `version.cue` for `#CanonCalVer`). Each step is stamped with the CalVer of the date it landed and listed **chronologically** — the order the cutovers were authored in, which is the only correct replay order for an arbitrarily-old config. `charly migrate` applies every step whose version is newer than the config's stamp; each step is idempotent, so applying the whole set is safe (an already-current file is a no-op).
+The migration steps are **declarative DATA, not code**: an ordered list embedded from **`candy/plugin-migrate/migrations.cue`**, validated at process start against the `#Migration` schema in **`candy/plugin-migrate/schema/migration.cue`**. That schema is `@go(-)` — it generates no Go type — and lives IN the plugin: it is validation-only, read by this engine and by no charly core code, so per the kernel/plugin boundary law it stays out of the SDK contract. The list is ORDERED — the order the cutovers were authored in, which is the only correct replay order — and `charly migrate` applies every step, in order.
-A step is a small record:
+A step is:
```cue
-{version: "YYYY.DDD.HHMM", name: "", touches_host?: bool, ops?: [...#Op] | apply?: ""}
+{name: string, touches_host?: bool, ops?: [...#MigrationOp] | apply?: string}
```
-`touches_host: true` flags a step that mutates per-host state (`~/.config/charly`, quadlets, `.secrets`) — those steps are skipped by remote-cache auto-migration (see below). A step carries EITHER an `ops:` list OR a single `apply:` hook, never both.
+`name` labels the step for `--dry-run` output and progress. `touches_host: true` flags a step that mutates per-host state (`~/.config/charly`, quadlets, `.secrets`) — those steps are skipped by remote-cache auto-migration (see below). A step carries EITHER an `ops:` list OR a single `apply:` hook, never both; that "exactly one of the two" rule is enforced in Go, since CUE's field-presence comparison is too fragile for the gate.
**The op vocabulary — each op is DATA, zero Go.** Four generic key transforms cover the common cutovers, all applied by ONE comment-preserving yaml.v3 interpreter (the op-walker in `candy/plugin-migrate/engine.go`):
| Op | Shape | Effect |
|---|---|---|
-| `rename_key` | `{from, to, scope, under_kind?}` | rename a mapping key `from` → `to` |
-| `delete_key` | `{key, scope, under_kind?}` | drop a mapping key |
+| `rename_key` | `{from, to, scope?="any", under_kind?}` | rename a mapping key `from` → `to` |
+| `delete_key` | `{key, scope?="any", under_kind?}` | drop a mapping key |
| `remap_scalar` | `{key, from, to, under_kind?}` | rewrite a scalar VALUE `from` → `to` at `key` |
| `move_key` | `{key, from_parent, to_parent, under_kind?}` | relocate a key between parent mappings |
-`scope` is `root` (top-level keys only) or `any` (every depth); `under_kind` further scopes an op to entities of a named kind (e.g. only `vm:` nodes). None of the four ops needs a line of new Go — the walker interprets the data.
+`scope` is `root` (top-level keys only) or `any` (every depth, the default; only `rename_key` / `delete_key` take it); `under_kind` further scopes an op to entities of a named kind (e.g. only `vm:` nodes). None of the four ops needs a line of new Go — the walker interprets the data.
-**The `apply:` hook — the deliberate, visible EXCEPTION.** A structural reshape the four ops can't express (splitting one node into siblings, folding a list into a map) sets `apply: ""` naming a Go function registered in the core `goHooks` map. This is the ONLY path that runs bespoke Go for a migration, and it is intentionally conspicuous: a table entry carrying `apply:` is the signal that this step needed real code.
+**The `apply:` hook — the deliberate, visible EXCEPTION.** A structural reshape the four ops can't express (splitting one node into siblings, folding a list into a map) sets `apply: ""` naming a Go function registered in the plugin's `goHooks` map. This is the ONLY path that runs bespoke Go for a migration, and it is intentionally conspicuous: a table entry carrying `apply:` is the signal that this step needed real code.
-**CUE has NO transformation capability.** CUE is monotone unification — it can validate, default, require, and close, and nothing more; it CANNOT rewrite a config. So CUE owns exactly two things here: the version pins (`#SchemaVersion` / `#SchemaFloor`) and the SHAPE of the declarative table (`#Migration` validates every entry at startup). Every actual transform is Go — the generic op-walker, or an `apply:` hook. Do not expect CUE to migrate anything.
+**CUE has NO transformation capability.** CUE is monotone unification — it can validate, default, require, and close, and nothing more; it CANNOT rewrite a config. So CUE owns exactly ONE thing here: the SHAPE of the declarative table (`#Migration` validates every entry at startup). Every actual transform is Go — the generic op-walker, or an `apply:` hook. Do not expect CUE to migrate anything.
-**The current table.** The floor sits at the migration-baseline reset version (`2026.174.1100`), so any config predating it is unmigratable — the accepted clean-slate consequence. The table carries ONE entry, the schema-compaction cutover:
+**The current table.** `candy/plugin-migrate/migrations.cue` is the authoritative list — read it there; each entry carries its own comment explaining the reshape and why the declarative ops could not express it. The entries run in the order the cutovers were authored in (the only correct replay order) and are:
-| version | name | touches_host | apply |
+| name | declarative ops | `apply:` hook | `touches_host` |
|---|---|---|---|
-| `2026.186.2323` | `compact-node-form` | `true` | `compactNodeForm` (goHook) |
-
-The `compactNodeForm` hook is a structural reshape the four generic ops can't express: it folds the former named data/step child-node grammar into the compact node form — collections (`env`/`volume`/`port`/`service`/…) inline in the kind value, steps as the ordered `plan:` list (a meaningful former step-node name becomes the step's `id:`), deploy data (`add_candy`/`install_opts`/`vm_state`/…) inline in the substrate node, and every authored step `plugin:`+`plugin_input:` pair rewritten to the `: ` verb sugar. Member children (sub-entities under a deployable kind) stay named children. Remote layer caches auto-migrate on fetch (see "Remote-cache auto-migration"). The same cutover DELETED the Calamares `target:` / `module:` / `package-group:` kinds (and their plugins) — they had zero core readers; they return as their own cutover when the installer feature is real.
+| `compact-node-form` | — | `compactNodeForm` | `true` |
+| `strip-candy-libvirt-field` | — | `stripCandyLibvirtField` | — |
+| `strip-deploy-shell-overlay` | — | `stripDeployShellOverlay` | `true` |
+| `k8s-to-kubernetes` | 2 × `rename_key` | — | — |
+| `remove-candy-localpkg` | `delete_key` | — | — |
+| `install-template-to-phases` | — | `installTemplateToPhases` | — |
+| `reshape-graphics-gl` | — | `reshapeGraphicsGL` | — |
+| `record-field-to-instrument` | — | `recordFieldToInstrument` | — |
+| `unroll-group-deploy` | — | `unrollGroupDeploy` | — |
+| `deploy-cpu-spelling` | — | `reshapeDeployCPU` | `true` |
+| `rekey-legacy-vm-overlay` | — | `rekeyLegacyVMOverlay` | `true` |
+
+`compact-node-form` is the schema-compaction cutover: it folds the former named data/step child-node grammar into the compact node form — collections (`env`/`volume`/`port`/`service`/…) inline in the kind value, steps as the ordered `plan:` list (a meaningful former step-node name becomes the step's `id:`), deploy data (`add_candy`/`install_opts`/`vm_state`/…) inline in the substrate node, and every authored step `plugin:`+`plugin_input:` pair rewritten to the `: ` verb sugar. Member children (sub-entities under a deployable kind) stay named children. Remote layer caches auto-migrate on fetch (see "Remote-cache auto-migration").
+
+On top of the table the engine ALWAYS applies one further step, **`strip-version-stamp`** — it is not a table entry, so it needs no author (see "There is no schema version").
## How it runs
-The migration engine is a **COMPILED-IN command plugin** (`candy/plugin-migrate`, `command:migrate`) — the engine, table, and CLI live in the plugin, out of charly core; core keeps only the below-floor/behind-head load-gate HINTS (the `Run: charly migrate` messages). `charly migrate` dispatches to the compiled-in `command:migrate` provider; the remote-cache auto-migration (`refs.go`) resolves the same provider and Invokes it (`OpRun --project-only`) in-proc.
+The migration engine is a **COMPILED-IN command plugin** (`candy/plugin-migrate`, `command:migrate`) — the engine, table, and CLI live in the plugin, out of charly core; core keeps only the load-gate HINT (the `Run: charly migrate` message a closed-schema load failure carries). `charly migrate` dispatches to the compiled-in `command:migrate` provider; the remote-cache auto-migration resolves the same provider and Invokes it (`OpRun --project-only`) in-proc. Compiled-in placement is load-bearing: migrate is whole-project file-based and must run when the config is exactly what cannot load, so it can never be discovered out-of-process.
-`runMigrations` (`candy/plugin-migrate/engine.go`) is **floor-gated**, comparing the config's `version:` stamp against `kit.SchemaFloor()` and `kit.LatestSchemaVersion()`:
+`runMigrations` (`candy/plugin-migrate/engine.go`) walks the table in declaration order:
-- **at HEAD** → no-op; prints `nothing to migrate (already at schema )`.
-- **below the floor, or not a CalVer at all** → **unmigratable**: refused with an actionable error (`predates the supported floor … re-author against the current schema`) and **NO filesystem change**. Configs from before the baseline reset fall here. A stranded **per-host** config (`~/.config/charly/charly.yml`) at a below-floor version is doubly stuck: it can neither migrate NOR be WRITTEN — `saveDeployState` refuses to overwrite a config it cannot load (`refusing to overwrite … the existing per-host config fails to load … fix it (or remove it to regenerate) first`), so every pod/vm/local deploy — including a `charly check run ` at its `config`/`deploy-add` step — fails until it is reset. Since it holds only regenerable deploy STATE (no authored intent), back it up and reset it to a bare `version: ` stub (or remove it); the state rebuilds on the next deploy.
-- **in `[floor, HEAD)`** → apply every migration-table step newer than the stamp (in order, via the op-walker / `apply:` hooks), then re-stamp every versioned file to HEAD (`universalStamp`).
+- **no `charly.yml` in the directory** → reports `no in — nothing to migrate`.
+- **otherwise** → applies every step (the generic op-walker or an `apply:` hook) in order, then the universal `strip-version-stamp`, and reports `nothing to migrate` when nothing changed.
-Per-step backups follow the established `.bak.` convention.
+**Backups are gitignored, never adjacent.** A step's per-file backup goes to `/.charly/backups/.`, and the tree is kept out of git by a `/.charly/.gitignore` containing a bare `*` (created on demand). A migration therefore never leaves `*.bak` files beside the originals and never dirties a working tree.
### Remote-cache auto-migration (project-only)
-`sdk/loaderkit`'s refs seams (the former `charly/refs.go` is DELETED, K-wave 2) auto-run the remote-cache migration (`migrateCacheViaPeer`, `sdk/loaderkit/refs_seams_executor.go`) on a freshly-cloned remote-repo cache so external repos pull through at the latest schema. It skips every `touches_host` step and leaves the host-deploy path empty, so a remote fetch **never mutates the user's per-host state** — even the final re-stamp touches only the cache's project files. A remote whose config predates the floor **fails the fetch** with the same predates-floor error (an old remote is unmigratable — the accepted clean-slate consequence).
-
-## Load-time gate
-
-The load-time gate `GateSchemaVersion` (`sdk/loaderkit/load_unified.go` — the former `charly/unified.go` is DELETED, K-wave 2) is unchanged: `LoadUnified` parses the merged `version:` and rejects anything below HEAD (or absent, or non-CalVer):
-
-```
-charly.yml: schema 2026.186.2323 is required (found "4"). Run: charly migrate
-```
-
-A non-CalVer value (a legacy integer, empty, or garbage) parses as "older than every real CalVer", so a stale config trips the gate with a uniform `Run: charly migrate` hint — the forward-compat trigger that makes a future migration fire. Whether that migrate then SUCCEEDS depends on the floor: a config in `[floor, HEAD)` migrates; one below the floor is refused (see "How it runs"). Residual-key checks (e.g. `kind: deployment`, `target: host`, `secret_backend: kdbx`) remain as defense-in-depth, but every remediation hint points uniformly at bare `charly migrate`.
+`sdk/loaderkit`'s refs seams auto-run the remote-cache migration on a freshly-cloned remote-repo cache so external repos pull through at the current schema. It skips every `touches_host` step and leaves the host-deploy path empty, so a remote fetch **never mutates the user's per-host state** — even the final `strip-version-stamp` touches only the cache's project files.
-## Adding a future cutover
+## Load-time enforcement: closedness
-**From here to the end of "Standing rule" is maintenance the charly project performs on its OWN schema**, run from checkouts of the spec repo and the superproject. Nothing in these two sections is a step an charly user runs — the user-facing surface of a schema cutover is the single command `charly migrate`, unchanged. They are recorded here because this skill owns the migration table; the commands are named as this project's maintenance, not as instructions for the reader.
+There is no version gate to trip. Compatibility has ONE mechanism — the **CLOSED CUE schema**: a retired key (a leftover `version:`, `kind: deployment`, `target: host`, `secret_backend: kdbx`, …) is an unknown field, and the load fails with a hard `field not allowed` diagnostic naming it. The same failure carries the `Run: charly migrate` hint, and `charly migrate` is what strips the key (see "There is no schema version"). Residual-key checks remain as defense-in-depth alongside the closedness gate.
-The payoff of the declarative engine: the common case needs **zero new Go** — but since the schema lives in the spec module, a schema-version bump is **CROSS-REPO**: the spec repo lands + tags FIRST, then the superproject consumes the published module at the bumped require version (no submodule, no local checkout).
+## Adding a future migration
-1. Bump `#SchemaVersion` in **`spec/schema/version.cue`** to the new CalVer.
-2. Run the spec repo's **`charly task cue-gen`** to regenerate `spec/spec/version_gen.go`.
-3. **Land + tag the spec repo** (its Go-module tag scheme `v0..` — see [`/charly-internals:git-workflow`](/recipes/internals/git-workflow/)).
-4. In the superproject: bump the `github.com/opencharly/spec` require version in `charly/go.mod` and every lockstep go.mod (the canonical-go.mod gate in `tools/gomod-canonical` asserts one shared pin; `charly task mods-tidy` re-syncs the go.sum files), append the matching entry to **`candy/plugin-migrate/migrations.cue`** (the TABLE lives in candy/plugin-migrate) with the same `version:` — **strictly greater** than the previous HEAD, expressing the transform as an `ops:` list (`rename_key` / `delete_key` / `remap_scalar` / `move_key`) — and run the superproject's own gates.
+**This section is maintenance the charly project performs on its OWN schema.** Nothing in it is a step a charly user runs — the user-facing surface is the single command `charly migrate`, unchanged. It is recorded here because this skill owns the migration table.
-That is the whole common case — no bespoke migrator type, no registry function, no per-migrator Go file. Only a **structural reshape the four ops can't express** additionally registers ONE `goHooks` entry (a Go function named by the step's `apply:` field). Update the HEAD-CalVer fixtures + the repo's own versioned YAML in the same change.
+The declarative engine's payoff: the common case needs **zero new Go**. Append ONE entry to **`candy/plugin-migrate/migrations.cue`**, expressing the transform as an `ops:` list (`rename_key` / `delete_key` / `remap_scalar` / `move_key`). Because the table is order-driven and version-free, there is no CalVer to bump and no cross-repo sequence — the entry IS the whole change, and it is idempotent by contract. A **structural reshape the four ops can't express** additionally registers ONE `goHooks` entry (a Go function named by the step's `apply:` field). Ship the entry in the same change that alters the schema, and update the repo's own versioned YAML.
The operator command never changes — it stays `charly migrate`.
-**The validated schema is CUE-single-source.** The `@go()`-annotated `spec/schema/*.cue` defs are the sole source for the Go param structs (generated into `spec/spec` by `charly task cue-gen`); a wire-key change is a CUE edit first, then `charly task cue-gen`, then — only if it breaks existing on-disk configs — a migration-table entry here (plus the `#SchemaVersion` bump above). A pure codegen refactor that leaves every authored wire key untouched needs NO migration entry and NO `version:` bump. See the [`/charly-internals:go`](/recipes/internals/go/) recipe "How to change the charly.yml schema (CUE is the single source of truth)".
+**The validated schema is CUE-single-source.** The `@go()`-annotated `spec/schema/*.cue` defs are the sole source for the Go param structs (generated into `spec/spec` by `charly task cue-gen`); a wire-key change is a CUE edit first, then `charly task cue-gen`, then — only if it breaks existing on-disk configs — a migration-table entry here. A pure codegen refactor that leaves every authored wire key untouched needs NO migration entry. See the [`/charly-internals:go`](/recipes/internals/go/) recipe "How to change the charly.yml schema (CUE is the single source of truth)".
-### Standing rule: a schema/format change bumps `version:` AND mints a git tag
+### Standing rule: a schema/format change ships its migration entry AND mints a git tag
-Any change to the YAML schema or composition format (a key rename, a deleted key, a new key shape) is a hard-cutover that MUST:
+Any change to the YAML schema or composition format (a key rename, a deleted key, a new key shape) is a hard-cutover that MUST ship a matching migration-table entry in the same change, so `charly migrate` can carry an existing config across. A migration entry WITHOUT the matching schema change (or vice-versa) is forbidden.
-1. **Bump the `#SchemaVersion` CalVer** — edit `spec/schema/version.cue`, run `charly task cue-gen`, and append the matching entry to `candy/plugin-migrate/migrations.cue` (the cross-repo sequence under "Adding a future cutover"), then land the sdk PR (the fresh `pr-validator` merges + tags it), and in the superproject adopt the tagged sdk release as the new shared pinned require (the `charly task mods-tidy` sweep + canonical-go.mod gate keep every module on the one pin). The load-time gate then rejects any not-yet-migrated config with a `Run: charly migrate` hint, so every reader sees the new format. Raising `#SchemaVersion` WITHOUT the migration-table entry (or vice-versa) is forbidden — and `version:` is NEVER set above `LatestSchemaVersion()` (newer configs hard-fail at load). **The author writes a PLACEHOLDER `#SchemaVersion` / `version:` / `migrations.cue` CalVer; the fresh `pr-validator` FINALIZES it at MERGE** — re-stamping it strictly ABOVE the current `main` HEAD's schema version (two concurrent schema PRs would otherwise collide or mis-order; see [`/charly-internals:git-workflow`](/recipes/internals/git-workflow/) "CalVer").
-2. **The fresh `pr-validator` mints the per-merge git tag** at merge — `v` from the merge moment (see "Per-merge release git tags" above). The tag and the `version:` bump are decoupled: the tag marks the merge, `version:` marks the schema. A schema cutover happens to do BOTH at once, but a content-only landing (no schema change) still mints a tag at an unchanged `version:`.
+Separately, **the fresh `pr-validator` mints the per-merge git tag** — `v` computed from the MERGE moment (see "Per-merge release git tags" above). The tag marks the MERGE and every landing gets one, schema change or not; it is independent of the migration table, which marks the SCHEMA.
## Idempotency
-Running `charly migrate` twice is a no-op: after the first run the config is stamped at HEAD, so the second run hits the floor gate's "at HEAD → nothing to migrate" branch. Every op is itself idempotent (a rename with no matching key does nothing). The migration table's own invariants — every entry a valid `#Migration`, versions strictly ascending and unique, HEAD == `#SchemaVersion` — are validated against CUE at process start.
+Running `charly migrate` twice is a no-op: every step's old shape is absent the second time, so each does nothing. An op with no matching key, a `strip-version-stamp` on a file with no stamp, and an `apply:` hook on an already-reshaped node are all inert. The table's own invariant — every entry a valid `#Migration` — is validated against CUE at process start.
## See Also
@@ -127,5 +122,5 @@ Running `charly migrate` twice is a no-op: after the first run the config is sta
- [`/charly-core:deploy`](/recipes/core/deploy/) — the deploy entries a migration may rewrite
- [`/charly-local:local-spec`](/recipes/local/local-spec/) — `kind: local` templates
- [`/charly-build:secrets`](/recipes/build/secrets/), [`/charly-build:settings`](/recipes/build/settings/) — the credential schema
-- [`/charly-internals:go`](/recipes/internals/go/) — loader internals (`LoadUnified`, `ParseCalVer`, the `gateSchemaVersion` load gate, the `candy/plugin-migrate/engine.go` op-walker)
+- [`/charly-internals:go`](/recipes/internals/go/) — loader internals (`LoadUnified` and the closedness gate; the `candy/plugin-migrate/engine.go` op-walker)
- [`/charly-internals:cutover-policy`](/recipes/internals/cutover-policy/) — why hard-cutover + a single idempotent `charly migrate` is the required shape
diff --git a/src/content/docs/recipes/build/pull.md b/src/content/docs/recipes/build/pull.md
index 519ed24e..f7bc0612 100644
--- a/src/content/docs/recipes/build/pull.md
+++ b/src/content/docs/recipes/build/pull.md
@@ -96,8 +96,8 @@ per-call-site code:
from local storage.
2. `dispatchBuildEnsure` (`charly/dispatch_build_ensure.go`) dispatches to
candy/plugin-build's `build:ensure` word (`candy/plugin-build/ensure.go`),
- which returns the same sentinel when the image is absent from both run and
- build engines.
+ which returns the same sentinel when the image is absent from the RUN engine's
+ store (the one every later read resolves it through).
3. `FormatCLIError` in `charly/image.go` unwraps the sentinel at the top-level
error boundary in `main()` and renders:
diff --git a/src/content/docs/recipes/build/reconcile.md b/src/content/docs/recipes/build/reconcile.md
index c2145b81..994bfe40 100644
--- a/src/content/docs/recipes/build/reconcile.md
+++ b/src/content/docs/recipes/build/reconcile.md
@@ -27,7 +27,7 @@ default mode is idempotent; `--remote` is not, because its target is whatever th
remote's newest tag is at the moment of the call — see "`--remote` targets whatever
is newest NOW" below before reaching for that flag.
-The **zero-warnings R10 gate** (the project rulebook R1 (`AGENTS.md` / `CLAUDE.md`)) makes this load-bearing: a change
+The **zero-warnings R10 gate** (the project rulebook R1 (`AGENTS.md`)) makes this load-bearing: a change
that introduces a version mismatch is not landable until `charly box reconcile`
clears the warning.
diff --git a/src/content/docs/recipes/build/settings.md b/src/content/docs/recipes/build/settings.md
index d36384f8..1dc07898 100644
--- a/src/content/docs/recipes/build/settings.md
+++ b/src/content/docs/recipes/build/settings.md
@@ -30,7 +30,6 @@ Manage charly's runtime configuration stored in `~/.config/charly/config.yml`. C
| Key | Default | Env Var | Description |
|-----|---------|---------|-------------|
-| `engine.build` | `auto` | `CHARLY_BUILD_ENGINE` | Build engine (docker/podman) |
| `engine.run` | `auto` | `CHARLY_RUN_ENGINE` | Run engine (docker/podman) |
| `run_mode` | `auto` | `CHARLY_RUN_MODE` | Deployment mode (quadlet/direct) |
| `bind_address` | `127.0.0.1` | `CHARLY_BIND_ADDRESS` | Default bind address for ports |
@@ -47,12 +46,11 @@ Manage charly's runtime configuration stored in `~/.config/charly/config.yml`. C
### Engine Selection
```bash
-# Switch to podman for both build and run
-charly settings set engine.build podman
+# Which engine RUNS containers (the builder is BuildKit, and is not selectable)
charly settings set engine.run podman
# Check current engine
-charly settings get engine.build
+charly settings get engine.run
```
### Storage Paths
diff --git a/src/content/docs/recipes/build/validate.md b/src/content/docs/recipes/build/validate.md
index f7065f56..1d6e5b53 100644
--- a/src/content/docs/recipes/build/validate.md
+++ b/src/content/docs/recipes/build/validate.md
@@ -216,7 +216,7 @@ The unified form is **name-first**: every entity flattens to a top-level `
- **Cross-FILE name reuse is fine.** The same name MAY exist simultaneously in SEPARATE discovered files — a layer `candy/redis/charly.yml` (a `candy:` node with no `base:`/`from:`) and an image `box/redis/charly.yml` (a `candy:` node carrying `base:`/`from:`) resolve to distinct internal maps (`uf.Candy` vs `uf.Box`, routed by `base:`/`from:` presence), and the validator does NOT flag them. Verbs disambiguate by command context (`ResolveDeployRef` is image-first; `--add-candy ` selects the layer-first path).
- **Same-document collision IS rejected.** Two top-level entities WITHIN one document (e.g. the root `charly.yml` carrying a `pod` + a `vm` + a `local`) MUST NOT share a name — they would collide on one YAML key. `charly box validate` flags the collision; rename one (the convention: keep the user-facing deploy, suffix the template it deploys — a `check-local` deploy + a `check-local-app` local template).
-The loader still raises hard load-time errors on obsolete keys — the obsolete `qc` / `cachyos-dx` deployment names, and any obsolete `kind: deployment` doc or root-key `deployment:` (the deploy kind is now the name-first substrate kind: `pod:` / `vm:` / `kubernetes:` / `local:` / `android:` / `group:`). Every such error points at `charly migrate`. See the project rulebook (`AGENTS.md` / `CLAUDE.md`) and [`/charly-build:migrate`](/recipes/build/migrate/).
+The loader still raises hard load-time errors on obsolete keys — the obsolete `qc` / `cachyos-dx` deployment names, and any obsolete `kind: deployment` doc or root-key `deployment:` (the deploy kind is now the name-first substrate kind: `pod:` / `vm:` / `kubernetes:` / `local:` / `android:` / `group:`). Every such error points at `charly migrate`. See the project rulebook (`AGENTS.md`) and [`/charly-build:migrate`](/recipes/build/migrate/).
## When to Use This Skill
diff --git a/src/content/docs/recipes/cache/cache.md b/src/content/docs/recipes/cache/cache.md
index 72a990d9..9c8ef77a 100644
--- a/src/content/docs/recipes/cache/cache.md
+++ b/src/content/docs/recipes/cache/cache.md
@@ -20,6 +20,28 @@ keeps so repeated resolutions do not re-fetch. It is served by `candy/plugin-cac
- `charly cache refresh` — drop the cache and note the next resolution re-warms the refs.
- `charly cache bypass` — persist the bypass; every resolution is fresh until turned off.
- `charly cache bypass --off` — turn the bypass off; the cache resumes.
+- `charly cache push ` — push a named cache (an OCI layout) to a registry.
+- `charly cache pull ` — pull a named cache from a registry into its local layout.
+- `charly cache ingest ` — ingest a remote image ref into a named store (the reserved
+ `registry` store by default), so charly's own registry can serve it. `--store `
+ chooses the store, `--insecure` allows a plain-HTTP dev registry, `--refresh` re-resolves
+ even when the ref is already stored.
+
+## Ingesting into the registry store
+
+`charly cache ingest ` is the write half of the registry capability. It resolves the
+ref's artifacts through `verb:oci` (the go-containerregistry transport stays single-homed in
+plugin-oci) and lands them in a named `sdk/storekit` store: every blob is written
+content-addressed by digest, then ONE entry keyed by the full ref — with the repo, the tag
+and the digest the tag resolved to in its annotations (D-STORE-5). A mutable name therefore
+resolves ONCE: a repeated `charly cache ingest ` is answered by the store with no second
+fetch, and a re-run at an unchanged digest writes no second blob (`--refresh` forces the
+re-resolution, not a rewrite). It needs the compiled-in placement, because the reverse
+channel to `verb:oci` exists only there.
+
+The reserved store is `registry`, under the CACHE root: `~/.cache/charly/registry`
+(`CHARLY_CACHE_DIR` overrides the root). That is `storekit`'s resolver — distinct from
+`charly cache push`/`pull`'s config-rooted `~/.config/charly/cache/` layouts.
## Where the cache lives
diff --git a/src/content/docs/recipes/candy-cli/candy.md b/src/content/docs/recipes/candy-cli/candy.md
index 3330db44..2b420f1b 100644
--- a/src/content/docs/recipes/candy-cli/candy.md
+++ b/src/content/docs/recipes/candy-cli/candy.md
@@ -19,6 +19,16 @@ description: "`charly candy set` / `charly candy add-` — mutate a candy's
- `charly candy add-` — append packages to the distro-format
section `` (e.g. `add-pac`, `add-deb`, `add-rpm`). The verb set derives from
`sectionDistroPath`, so it cannot drift from the mapped formats.
+- `charly candy params [--check]` — regenerate
+ `candy//params/cue_types_gen.go` from `candy//schema/*.cue`. This is
+ the pipeline's ONLY documented entry point: concatenate the schema (the
+ `schemaconcat` contract), head it `package params` + `@go(params)`, run the pinned
+ `cue exp gengotypes` (the `cuetoolchain` pin — provisioned checksum-verified into
+ the charly cache, so no `cue` install and no checkout are needed), then double
+ every json tag with a yaml tag (the `schemaretag` contract). `--check` writes
+ nothing: it exits non-zero when the committed file differs from that pipeline
+ OUTPUT, which is the drift gate a plugin repo's CI should run. `--schema/--pkg/--out`
+ is the explicit form for a schema that is not a candy's.
## When to use it
diff --git a/src/content/docs/recipes/check/adb.md b/src/content/docs/recipes/check/adb.md
index 5ca15c4a..6c78f39c 100644
--- a/src/content/docs/recipes/check/adb.md
+++ b/src/content/docs/recipes/check/adb.md
@@ -40,7 +40,7 @@ adb-exclusive fields (`arg:`, `apk:`, `property:`, `key:`, `query:`, `amount:`,
`artifact:` and the artifact validators) — those live INSIDE the `adb:` map. Only
the shared matchers (`stdout:`, `stderr:`, `exit_status:`) and `context:`/`id:`/
`timeout:` stay siblings.
-**`context: [deploy]` only** — needs a running container with a
+**`context: [runtime]` only** — needs a live deployment (a running container with a
host-mapped ADB server port; the validator rejects `context: [build]`
use at `charly box validate` time.
@@ -51,7 +51,7 @@ Example — each step is an ordered list item under the candy/box `plan:` (mirro
- check: the emulator is attached to the adb server
id: emulator-is-up
adb: devices
- context: [deploy]
+ context: [runtime]
stdout:
- contains: "emulator-5554"
- check: the emulator reports boot completed
@@ -59,7 +59,7 @@ Example — each step is an ordered list item under the candy/box `plan:` (mirro
adb:
method: getprop
property: sys.boot_completed
- context: [deploy]
+ context: [runtime]
stdout:
- contains: "1"
timeout: 300s
@@ -171,7 +171,7 @@ is immune to the stdin-heredoc hazard that breaks shell-based settle loops
- check: the emulator UI has settled past the ANR churn
id: emulator-ui-settled
adb: wait-ui-settled
- context: [deploy]
+ context: [runtime]
timeout: 600s
stdout:
- contains: settled
@@ -197,7 +197,7 @@ Common pattern:
- check: wait for the device to finish booting
adb: wait-for-device
timeout: 120s
- context: [deploy]
+ context: [runtime]
```
## Implementation
diff --git a/src/content/docs/recipes/check/android-sdk.md b/src/content/docs/recipes/check/android-sdk.md
new file mode 100644
index 00000000..b6e98ee8
--- /dev/null
+++ b/src/content/docs/recipes/check/android-sdk.md
@@ -0,0 +1,60 @@
+---
+title: "android-sdk"
+description: "The Android SDK toolchain candy (sdkmanager/adb/aapt2/emulator + the API-36 system image under /opt/android-sdk)."
+---
+
+
+
+*Recipe card from the `charly-check` plugin (Commands — runtime CLI verbs).*
+
+# android-sdk — the Android SDK toolchain candy
+
+## Candy Properties
+
+| Property | Value |
+|----------|-------|
+| Install root | `/opt/android-sdk` |
+| Requires | `@github.com/opencharly/layer-java-openjdk` (JDK 21) |
+| Env | `ANDROID_HOME`, `ANDROID_SDK_ROOT` = `/opt/android-sdk` |
+| System image | API-36 `google_apis_playstore` x86_64 (sdkmanager-fetched) |
+
+## What it installs
+
+From CachyOS/Arch packages under `/opt/android-sdk`:
+`cmdline-tools` (`sdkmanager`/`avdmanager`), `platform-tools` (`adb`),
+`build-tools` 36 (`aapt2`), `platform android-36`, and the `emulator`.
+`apkeep` (the by-package-name app downloader the `charly check adb
+install-app` verb drives) is installed from its upstream precompiled
+x86_64 binary. The one component with no package anywhere — the API-36
+`google_apis_playstore` x86_64 system image — is fetched by `sdkmanager`
+into a persistent cache and copied alongside the tools.
+
+## Usage
+
+Compose it in a box (a named box's `candy:` value is the box BODY):
+
+```yaml
+android-dev:
+ candy:
+ base: cachyos-base
+ candy:
+ - '@github.com/opencharly/layer-android-sdk:v2026.xxx.yyyy'
+```
+
+The SDK lands at fixed paths under `/opt/android-sdk`, so it is verifiable
+by probing those paths and running `adb version`. Pair it with an
+emulator device (`kind: android`) and the `adb:`/`appium:` check verbs.
+
+## Verification
+
+`plan:` `check:` steps assert `/opt/android-sdk/cmdline-tools/latest/bin/sdkmanager`,
+`.../platform-tools/adb`, `.../build-tools/36.0.0/aapt2`, `.../emulator/emulator`,
+`.../platforms/android-36`, and the fetched system-image directory all exist,
+that `adb version` reports the Android Debug Bridge banner, and that `apkeep`
+resolves on PATH.
+
+## Related
+
+- [`/charly-check:android`](/recipes/check/android/) — the `kind: android` device + `target: android` deploy
+- [`/charly-check:adb`](/recipes/check/adb/) — the `adb:` probe verb
+- [`/charly-check:appium`](/recipes/check/appium/) — the `appium:` UI-automation verb
diff --git a/src/content/docs/recipes/check/android-test-apps.md b/src/content/docs/recipes/check/android-test-apps.md
new file mode 100644
index 00000000..4c6d855c
--- /dev/null
+++ b/src/content/docs/recipes/check/android-test-apps.md
@@ -0,0 +1,55 @@
+---
+title: "android-test-apps"
+description: "The Android test-apps candy — a committed F-Droid APK applied to a `kind: android` device through the `apk:` package format."
+---
+
+
+
+*Recipe card from the `charly-check` plugin (Commands — runtime CLI verbs).*
+
+# android-test-apps — Android fixture apps via the `apk:` format
+
+## Candy Properties
+
+| Property | Value |
+|----------|-------|
+| Installs into image | nothing (device-scoped) |
+| `apk:` list | `tests/data/F-Droid.apk` (committed) |
+| Applied by | a `target: android` deploy (skipped at image build) |
+| Verified by | `adb:` `pm list packages` / `pidof` after the deploy |
+
+## What it does
+
+Carries the F-Droid client (`org.fdroid.fdroid`) as a **committed APK**
+(`tests/data/F-Droid.apk`), pushed over the goadb sync protocol and
+installed with `pm install`. It is the declarative counterpart to the
+`charly check adb install-app` verb. The candy installs **nothing** into
+the image — the `apk:` list is device-scoped and applied ONLY by a
+`target: android` deploy (via the external `deploy:android` substrate,
+venue-agnostic for an in-pod image device or a remote endpoint).
+
+## Usage
+
+Compose it in the box that backs a `kind: android` deploy:
+
+```yaml
+android-fixtures:
+ candy:
+ base: cachyos-base
+ candy:
+ - '@github.com/opencharly/layer-android-test-apps:v2026.xxx.yyyy'
+ - '@github.com/opencharly/layer-android-apidemos:v2026.xxx.yyyy'
+```
+
+## Verification
+
+The `plan:` `check:` steps run at `context: [runtime]` after a
+`target: android` deploy: `pm list packages` must contain
+`org.fdroid.fdroid`, and launching it (`monkey … LAUNCHER`) must yield a
+running PID — proving the install end to end over `adb`.
+
+## Related
+
+- [`/charly-check:android`](/recipes/check/android/) — the `kind: android` device + `apk:` format
+- [`/charly-check:adb`](/recipes/check/adb/) — the `adb:` probe verb
+- `layer-android-apidemos` — the sibling committed-APK fixture
diff --git a/src/content/docs/recipes/check/android.md b/src/content/docs/recipes/check/android.md
index 45c2a50f..e7e3080b 100644
--- a/src/content/docs/recipes/check/android.md
+++ b/src/content/docs/recipes/check/android.md
@@ -26,7 +26,7 @@ This sits ABOVE the device-interaction verbs: the `adb:` ([`/charly-check:adb`](
and `appium:` ([`/charly-check:appium`](/recipes/check/appium/)) declarative check verbs drive a running
device; `kind: android` + `target: android` declaratively describe a device and
the apps installed on it. The install machinery is shared — see "One installer
-(R3)".
+(R2)".
## `kind: android` — the device
@@ -106,11 +106,11 @@ android-stack:
- my-android-apps # layers whose apk: packages install onto the device
```
-`charly fleet add android-stack.device` resolves the device, gates on
+`charly deploy add android-stack.device` resolves the device, gates on
`sys.boot_completed`, and installs the `add_candy:` candies' `apk:` packages via
the **external `deploy:android` substrate** (F1 — served out-of-process by
candy/plugin-adb). Apps ride in on `add_candy:` (the same overlay mechanism
-local/vm targets use) — there is no separate apk-list field. `charly fleet del`
+local/vm targets use) — there is no separate apk-list field. `charly deploy del`
best-effort `pm uninstall`s each `package:` id (replayed from the deploy's
recorded reverse ops; the device/pod lifecycle is owned by the pod deploy).
@@ -125,12 +125,12 @@ apps deploy onto the device. `target: android` is a **passthrough** hop in the
deploy chain (the device shares its host pod's adb venue / the endpoint addr —
there is no shell venue to "enter"), so `charly check live pod.android` runs the
device's checks against the pod's published adb port. A pod's children can only
-deploy AFTER `charly start` (the container doesn't exist at `charly fleet add` time),
-so `charly fleet add --node-only` brings the pod up first and the children deploy
+deploy AFTER `charly start` (the container doesn't exist at `charly deploy add` time),
+so `charly deploy add --node-only` brings the pod up first and the children deploy
afterwards by dotted path; `charly check run ` automates this (deploy pod →
config → start → deploy nested children → check-live).
-## One installer + one plugin (R3 / F1)
+## One installer + one plugin (R2 / F1)
candy/plugin-adb owns ALL Android device interaction — the `adb:` check verb,
the `deploy:android` substrate, AND the goadb-backed `charly status` probe — so
@@ -157,7 +157,7 @@ round trip remains in this leg:
- **Plugin** (`candy/plugin-adb/deploy.go`, the `deploy:android` provider) gates
on `sys.boot_completed`, installs each app with retry (reusing the SAME
`install`/`install-app` method handlers the `adb:` verb dispatches), and returns
- the `pm uninstall` teardown ops the host records + replays at `charly fleet del`.
+ the `pm uninstall` teardown ops the host records + replays at `charly deploy del`.
So the apk format, the check verbs, and the deploy substrate can never drift on
single/split/`.xapk` handling — they all flow through the one provider.
@@ -211,7 +211,7 @@ ALL pure/portable and run plugin-side directly, returning a
- `deploykit.ApkInstallStep` (`sdk/deploykit/steps.go`); `sdk/deploykit/install_build.go` —
`compileApkStep` (the plugin-side preresolver reads this step; no deploy target executes it).
- `sdk/loaderkit/load_unified.go` — loader wiring (mirrors every `kubernetes` site; the former `charly/unified.go` is DELETED, K-wave 2).
-- `spec.Deploy` (the deploy node — the former `charly/deploy.go`'s `FleetNode` is DELETED, K-wave 2) with `target: android`; `charly/fleet_add_cmd.go` dispatch +
+- `spec.Deploy` (the deploy node — the former `charly/deploy.go`'s `FleetNode` is DELETED, K-wave 2) with `target: android`; `charly/deploy_add_cmd.go` dispatch +
`--node-only`; `sdk/deploykit/deploy_chain.go` / `charly/deploy_tree.go` passthrough.
## Related skills
diff --git a/src/content/docs/recipes/check/appium.md b/src/content/docs/recipes/check/appium.md
index 00e226d7..a17db2f5 100644
--- a/src/content/docs/recipes/check/appium.md
+++ b/src/content/docs/recipes/check/appium.md
@@ -38,7 +38,7 @@ the W3C escape hatch: `POST /session//execute/sync` with
### Authoring the `appium:` verb in a plan step
The verb is the inline Op of a step — an ordered list item under the candy/box
-`plan:` (deploy-context only — `context: [deploy]`); an Appium action that drives the
+`plan:` (**live-mode only** — `context: [runtime]`); an Appium action that drives the
UI is a `run:` step. The method name is the scalar value for a bare-method step
(`appium: status`), or the `method:` key of the `appium:` map when the step carries
appium-exclusive fields (`caps:`, `apk:`, `strategy:`, `selector:`, `text:`,
@@ -52,7 +52,7 @@ validators) — those live INSIDE the `appium:` map. Only the shared matchers
id: appium-up
appium: status
stdout: { contains: '"ready":true' }
- context: [deploy]
+ context: [runtime]
- run: open a W3C WebDriver session against the emulator
id: open-session
appium:
@@ -79,7 +79,7 @@ validators) — those live INSIDE the `appium:` map. Only the shared matchers
method: screenshot
artifact: /tmp/post-tap.png
artifact_min_bytes: 10000
- context: [deploy]
+ context: [runtime]
- run: close the WebDriver session
id: close
appium: session-delete
@@ -305,7 +305,7 @@ Override the session id for a single check with `session:`:
method: screenshot
artifact: /tmp/x.png
session: 37e8f3c1-... # bypass the session file
- context: [deploy]
+ context: [runtime]
```
## Implementation
diff --git a/src/content/docs/recipes/check/cdp.md b/src/content/docs/recipes/check/cdp.md
index b71f255b..8705a4ca 100644
--- a/src/content/docs/recipes/check/cdp.md
+++ b/src/content/docs/recipes/check/cdp.md
@@ -37,13 +37,13 @@ cdp-exclusive field (`tab:`, `expression:`, `url:`, `selector:`, `text:`, `x:`,
`artifact:`, `artifact_min_bytes:`, …) — those fields live INSIDE the `cdp:` map. The
shared matchers (`stdout:`, `stderr:`, `exit_status:`) and `context:`/`id:`/`timeout:`
stay siblings of the `cdp:` key. A query is a `check:` step; a navigation/click action
-is a `run:` step. All `cdp:` steps are **deploy-context only** (they need a running
-container), so author them with `context: [deploy]`. See [`/charly-check:check`](/recipes/check/check/) for the
+is a `run:` step. All `cdp:` steps are **live-mode only** (they need a running
+container), so author them with `context: [runtime]`. See [`/charly-check:check`](/recipes/check/check/) for the
full method allowlist and YAML shape. Example:
```yaml
- check: the page title is Dashboard
- context: [deploy]
+ context: [runtime]
cdp:
method: eval
tab: "1"
@@ -124,7 +124,7 @@ tab ID between steps; reference subsequent steps as `tab: "1"`).
```yaml
- check: a tab is open
- context: [deploy]
+ context: [runtime]
cdp: list # scalar sugar — bare method, no cdp-exclusive fields
stdout:
contains: example.com
@@ -149,7 +149,7 @@ Uses HTTP API: `GET /json/close/`.
```yaml
- check: the page text contains the marker
- context: [deploy]
+ context: [runtime]
cdp:
method: text # cdp: html → HTML source; cdp: url → title and URL
tab: "1"
@@ -163,7 +163,7 @@ Uses CDP WebSocket: `Runtime.evaluate` with `document.body.innerText` / `documen
```yaml
- check: a non-empty screenshot is captured
- context: [deploy]
+ context: [runtime]
cdp:
method: screenshot
tab: "1"
@@ -207,7 +207,7 @@ CDP coordinates are **viewport-relative** (relative to Chrome's content area). V
```yaml
- check: the sync button is located
- context: [deploy]
+ context: [runtime]
cdp:
method: coords
tab: "1"
@@ -249,7 +249,7 @@ above, then author a `wl: click` step at those `x:`/`y:`:
```yaml
- check: read the document title
- context: [deploy]
+ context: [runtime]
cdp:
method: eval
tab: "1"
@@ -265,7 +265,7 @@ Uses CDP: `Runtime.evaluate`. Returns the result value (e.g.
```yaml
- check: the heading appears
- context: [deploy]
+ context: [runtime]
cdp:
method: wait
tab: "1"
@@ -421,7 +421,7 @@ then deliver the click via the `vnc:` verb at the reported desktop center:
```yaml
- check: the sign-in button is located
- context: [deploy]
+ context: [runtime]
cdp:
method: coords
tab: "1"
@@ -435,14 +435,14 @@ ID survives Google's same-tab navigations (email → password → result).
```yaml
- check: the email field appears
- context: [deploy]
+ context: [runtime]
cdp:
method: wait
tab: "1"
selector: "#identifierId"
timeout: 30s
- check: the email field is located (focus via vnc: at these coords)
- context: [deploy]
+ context: [runtime]
cdp:
method: coords
tab: "1"
@@ -461,14 +461,14 @@ transition:
```yaml
- check: the password challenge page is reached
- context: [deploy]
+ context: [runtime]
cdp:
method: url
tab: "1"
stdout:
contains: challenge/pwd
- check: a verification screenshot is captured
- context: [deploy]
+ context: [runtime]
cdp:
method: screenshot
tab: "1"
@@ -480,14 +480,14 @@ transition:
```yaml
- check: the password field appears
- context: [deploy]
+ context: [runtime]
cdp:
method: wait
tab: "1"
selector: input[type="password"]
timeout: 15s
- check: the password field is located (focus via vnc:, then vnc: type "$GMAIL_PASSWORD")
- context: [deploy]
+ context: [runtime]
cdp:
method: coords
tab: "1"
@@ -539,7 +539,7 @@ The `cdp: spa-*` methods provide first-class support for interacting with Selkie
```yaml
- check: the SPA is in a healthy state
- context: [deploy]
+ context: [runtime]
cdp:
method: spa-status
tab: "1"
@@ -598,7 +598,7 @@ on an instance, then run `charly check live -i --filter cdp`:
```yaml
- check: CDP is available on port 9222
- context: [deploy]
+ context: [runtime]
cdp: status
stdout:
equals: ok
@@ -608,7 +608,7 @@ on an instance, then run `charly check live -i --filter cdp`:
method: open
url: https://ip.me
- check: the proxy IP is reflected by the page
- context: [deploy]
+ context: [runtime]
cdp:
method: eval
tab: "1"
diff --git a/src/content/docs/recipes/check/check-sway-browser-vnc.md b/src/content/docs/recipes/check/check-sway-browser-vnc.md
index c4ab2d6f..69cc6749 100644
--- a/src/content/docs/recipes/check/check-sway-browser-vnc.md
+++ b/src/content/docs/recipes/check/check-sway-browser-vnc.md
@@ -33,21 +33,21 @@ check-sway-browser-vnc-pod:
plan:
- check: CDP /json/version answers over the published HOST_PORT
id: esbv-pod-http-cdp
- context: [deploy]
+ context: [runtime]
http:
http: "http://127.0.0.1:${HOST_PORT:9222}/json/version"
status: 200
- check: CDP enumerates the live debugging targets
id: esbv-pod-cdp-list
- context: [deploy]
+ context: [runtime]
cdp: list
- check: the Sway tree is reachable over the Wayland socket
id: esbv-pod-wl-sway-tree
- context: [deploy]
+ context: [runtime]
wl: sway-tree
- check: a terminal recording starts
id: esbv-pod-record-start
- context: [deploy]
+ context: [runtime]
record:
method: start
record_name: check-term
@@ -86,7 +86,7 @@ Note: `charly config ` persists `image: ` (it assumes deploy-key ==
image-name; see [`/charly-core:deploy`](/recipes/core/deploy/)). Because this bed's key
(`check-sway-browser-vnc-pod`) differs from its image (`sway-browser-vnc`), set
the operator ref to `sway-browser-vnc` in `~/.config/charly/charly.yml` (the check
-runner / `charly fleet add` does this for you — `charly` "never clobbers
+runner / `charly deploy add` does this for you — `charly` "never clobbers
operator-authored refs").
## Related Skills
diff --git a/src/content/docs/recipes/check/check.md b/src/content/docs/recipes/check/check.md
index bb1ba887..08595dab 100644
--- a/src/content/docs/recipes/check/check.md
+++ b/src/content/docs/recipes/check/check.md
@@ -120,6 +120,37 @@ feature run`. Every candy MUST ship a non-empty `description:` + at least one
deterministic `check:` step (`charly box validate` enforces it); the live agent
grader stays opt-in. Full detail: [`references/authoring-gotchas.md`](/recipes/check/check/authoring-gotchas/).
+## The three-step bed shape — a gate that can FAIL without the change
+
+*This is the shape every `check:` bed the R10 gate selects should have, and the one this org's best beds use.*
+
+**A bed that only inspects the FINAL state cannot tell "my change worked" from "something else made it true" — and
+cannot fail without the change, which is R7's real test.** The shape that can:
+
+1. **ASSERT THE ABSENCE — before the change.** *Probe the thing your change will produce, and assert it is NOT there
+ yet: `nothing-served-before-the-delivery`. If the probe already succeeds on the un-changed tree, the bed proves
+ nothing about your change.*
+2. **PERFORM THE CHANGE.** *The `run:` step, or the deploy the bed exists to exercise.*
+3. **ASSERT THE PRESENCE — after it, in terms ONLY the change could produce.** *`delivers-the-built-tree`,
+ `venue-serves-the-delivered-word`.* **The assertion must be specific enough that a coincidence does not satisfy it.**
+
+**A working example in this org:** `spec#245`'s `check-spec-delivery` bed, whose three steps are named exactly along this
+shape. *Six PRs in one leg failed by omitting step 1.* **If you cannot write step 1, you have not decided what your change
+produces — which is worth knowing before the gate runs, not after.**
+
+## The uppercase-token trap — a step that SKIPS is not a step that PASSES
+
+**A step's body is scanned for variables INCLUDING ITS COMMENTS, and an unresolvable UPPER-CASE token makes the step
+SKIP rather than fail.** This is the single most expensive authoring trap in the check system, because a skipped step
+reads as a satisfied one in the summary.
+
+**So:**
+- **do not put an upper-case `${...}`-looking token in a step's body or in a comment inside it**, even to explain
+ something. *A comment is scanned.*
+- **verify the step table says `PASS`, not `SKIP`**, after a run that you are quoting as evidence. `2 steps: 2 passed,
+ 0 skipped` is the line to look for; a bed that reports skips has not proven what you are claiming it proved.
+- **when a variable genuinely is needed, use the bed's declared `var:` mechanism** so the value is resolvable at the
+ point the step runs.
## Quick Reference
| Action | Command | Description |
diff --git a/src/content/docs/recipes/check/check/authoring-gotchas.md b/src/content/docs/recipes/check/check/authoring-gotchas.md
index 4e7c961f..1117941c 100644
--- a/src/content/docs/recipes/check/check/authoring-gotchas.md
+++ b/src/content/docs/recipes/check/check/authoring-gotchas.md
@@ -26,7 +26,7 @@ steps to the agent grader.
| Stage | Who | Command | What |
|---|---|---|---|
-| **Specify** | you / an agent | hand-edit the candy's `plan:` list (or `charly box set candy..plan ...`) | Author the `description:` string + `plan:` steps on the CANDY that provides the behaviour (it bakes into every box that composes the candy — R3). |
+| **Specify** | you / an agent | hand-edit the candy's `plan:` list (or `charly box set candy..plan ...`) | Author the `description:` string + `plan:` steps on the CANDY that provides the behaviour (it bakes into every box that composes the candy — R2). |
| **Bind** | author (implicit) | `charly feature pending ` lists the agent-graded steps | Use a `check:` step (inline verb) → deterministic; use an `agent-check:` step (prose) → agent-graded. |
| **Run** | you / CI | `charly box feature run ` / `charly check feature run ` | Execute the plan; per-step pass/fail + grader evidence. |
| **Iterate** | you / an agent | edit + re-run, OR `charly check run ` (an `iterate:` bed) | Drive red→green by hand, or let the plateau-bounded AI loop write the code until the `check:` steps pass. |
@@ -116,7 +116,7 @@ in P12a — core constructs `&AgentGrader{}` directly, so it lives in `kit`, rea
core code), and `sdk/deploykit/description_collect.go` (`CollectDescriptions`; the former `charly/description_collect.go` is DELETED, K-wave 2) for the `charly feature
list/pending/validate` surface. The `Runner.Grader` dispatch is `kit.RunPlan` (`sdk/kit/planrun.go`)
directly — the former 1-line `charly/description_run.go` wrapper was dissolved in P12a.
-Target resolution is shared with the harness loop and `charly check box`/`live` (R3).
+Target resolution is shared with the harness loop and `charly check box`/`live` (R2).
## Authoring Gotchas (learned the hard way)
@@ -268,10 +268,10 @@ them fails in `charly check box` because the mount isn't attached.
file: /workspace
filetype: directory
-# ✓ context: [deploy] so it runs only against live containers
+# ✓ context: [runtime] so it runs only against live deployments
- check: the workspace directory exists
id: workspace-dir
- context: [deploy]
+ context: [runtime]
file:
file: /workspace
filetype: directory
@@ -367,23 +367,23 @@ the shell is a login shell — reproduced: `runuser -l user -s /bin/bash
-n -l` prints the full NOPASSWD listing. This bit the earlier shipping
form of the test; the sshd candy now uses `-u … --`.
-Worked example: [`/charly-coder:sshd`](/recipes/coder/sshd/) ships exactly the `-u user --` pattern. Alternative: give the step `context: [deploy]` so it runs against a live container where you control the user-switch externally.
+Worked example: [`/charly-coder:sshd`](/recipes/coder/sshd/) ships exactly the `-u user --` pattern. Alternative: give the step `context: [runtime]` so it runs against a live container where you control the user-switch externally.
### 12. `charly check box ` is ambiguous with multiple CalVer tags — use the full registry ref
`charly check box` resolves its positional argument against local podman storage, not `charly.yml`. When the host has accumulated many CalVer tags for the same image (a normal consequence of iterative `charly box build` runs), the short form errors out:
```
-charly: error: ambiguous short name "openclaw-desktop" in local storage;
- candidates: ghcr.io/opencharly/openclaw-desktop:latest,
- ghcr.io/opencharly/openclaw-desktop:2026.109.1418,
+charly: error: ambiguous short name "my-box" in local storage;
+ candidates: ghcr.io/opencharly/my-box:latest,
+ ghcr.io/opencharly/my-box:,
... Re-run with a full ref.
```
Use the fully-qualified registry ref:
```bash
-charly check box ghcr.io/opencharly/openclaw-desktop:latest
+charly check box ghcr.io/opencharly/my-box:latest
```
This is different from `charly box inspect`, `charly box build`, and `charly check live` (the live-service runner), which key off `charly.yml` and accept short names unambiguously. Only the disposable-container runner has this restriction because it does not consult `charly.yml` at all.
@@ -413,31 +413,51 @@ when the pattern would also match the wrapping bash. The harness
defense is a safety net, not a license to use the broken pattern
deliberately.
-### 13. A step is SIGKILLed at 90s unless it declares `timeout:`
-
-`ProbeNeverHang` (`sdk/kit/runner.go`) bounds every probe ATTEMPT at
-`runnerProbeNeverHangFallback` = **90 seconds**. A step that runs longer is killed,
-and the runner names the bound in the message — but the underlying exec-layer text is
-still `process terminated by signal (signal: killed)`, which describes the mechanism
-of death and not its cause. Read alone, it looks like the command crashed.
-
-What crosses 90s in practice: **installing charly from a published distro repo pulls
-~190 packages** (podman, qemu, libvirt, gnupg, tailscale). On a 2-vCPU cloud-image VM
-`dnf install` does not finish in time while `pacman -S` does — so two beds differing
-only in package manager come out one green and one "killed", which invites a
-distro-specific theory (repo layout, signing, cloud image) when the variable is
-elapsed time against a shared floor. **Compare the step's duration to 90s before
-theorising about the distro.**
+### 13. A step is SIGKILLed at its never-hang bound unless it declares `timeout:`
+
+`ProbeNeverHang` (`sdk/kit/runner.go`) bounds every probe ATTEMPT. The
+live `charly check` path sets that bound from the readiness table —
+`cfg.ProbeTimeout = poll.ReadinessProvider().PerAttemptFor(PollLocal)`
+(`candy/plugin-check/plugin_runner.go`), whose class fallback is
+`readinessPerAttemptFallback` = **120 seconds** (`spec/poll/poll.go`).
+The `runnerProbeNeverHangFallback` = 90s constant in `sdk/kit/runner.go`
+is ONLY the bare-`NewRunner` guard (a test / a plugin); it is never the
+bound on the real check paths. **Read the number out of the kill message
+rather than assuming** — an actual run prints
+`killed by the per-attempt never-hang bound of 2m0s`. A step that runs
+longer is killed, and the underlying exec-layer text is still
+`process terminated by signal (signal: killed)`, which describes the
+mechanism of death and not its cause. Read alone, it looks like a crash.
+
+What crosses the bound in practice is any step whose own work is
+minutes-long:
+
+- **Installing charly from a published distro repo pulls ~190 packages**
+ (podman, qemu, libvirt, gnupg, tailscale). On a 2-vCPU cloud-image VM
+ `dnf install` does not finish in time while `pacman -S` does — so two
+ beds differing only in package manager come out one green and one
+ "killed", which invites a distro-specific theory (repo layout, signing,
+ cloud image) when the variable is elapsed time against a shared floor.
+- **A full nested-container acceptance loop** — e.g. `cbx-e2e-loop`,
+ which leases a real nested container via `local-container`, syncs a git
+ workdir and streams a command. Its own work is minutes-long: on
+ distro-cachyos the enclosing `check-live` bucket (129 checks incl. this
+ one) measures **2m39s idle and 3m07s under concurrent host load** —
+ both above the 120s default, so the step is killed by the bound on its
+ own duration, not by a wedge (before the bound was declared it was
+ killed at `2m0s`; with `timeout: 10m` it PASSes). A check bed's long
+ acceptance steps are a normal case for an authored bound — not a sign
+ of a hang.
The fix is authored, not code: `#Op` carries `timeout?: #Duration`, and
-`ProbeNeverHang` honours a longer authored value over the floor (the effective
-ceiling becomes `declared + 30s`). Declaring it is USING the mechanism's own
-parameter — not an R4 workaround:
+`ProbeNeverHang` honours a longer authored value over the floor (the
+effective ceiling becomes `declared + 30s`). Declaring it is USING the
+mechanism's own parameter — not an R3 workaround:
```yaml
- check: dnf install charly installs the package
id: fedora-charly-installed
- timeout: 10m # ~190 packages on 2 vCPUs; the 90s floor kills this
+ timeout: 10m # ~190 packages on 2 vCPUs; the floor kills this
exit_status: 0
command: |
sudo dnf install -y charly &&
diff --git a/src/content/docs/recipes/check/check/beds-and-r10.md b/src/content/docs/recipes/check/check/beds-and-r10.md
index 021d52bc..e58b97ef 100644
--- a/src/content/docs/recipes/check/check/beds-and-r10.md
+++ b/src/content/docs/recipes/check/check/beds-and-r10.md
@@ -39,7 +39,7 @@ never by stripping the candy.
1. `charly box build --dev-local-pkg` — build the test artifact (pod beds only). The check-bed runner sets `--dev-local-pkg` automatically, so a bed bakes the **in-development** charly toolchain (built via the `charly generate-packages` plugin from the local binary + plugins) — never a stale published release. A bed thus always tests the code under development; a production box build downloads the released package. See [`/charly-tools:charly`](/recipes/tools/charly/) "Check-vs-production binary source" + [`/charly-internals:install-plan`](/recipes/internals/install-plan/) "Check-vs-production charly toolchain".
2. `charly check box ` — box-section + baked candy-section probes.
-3. `charly fleet add ` — apply the bed (or `charly vm create` for vm beds).
+3. `charly deploy add ` — apply the bed (or `charly vm create` for vm beds).
4. (pod beds) `charly config ` + `charly start `.
5. `charly check live ` — full three-section live probe pass.
6. `charly update ` — fresh-rebuild re-verification (R10 acceptance gate).
@@ -184,7 +184,6 @@ bed name against the live tree before launching it.
| `check-jupyter-pod` / `check-jupyter-ml-pod` | pod | `image: jupyter` / `image: jupyter-ml` | jupyter-mcp regression coverage; jupyter-ml spacy/quarto + GPU MCP probes |
| `check-versa-pod` | pod | `image: versa` | versa OSM analytics + vector-tile + marimo MCP |
| `check-android-emulator-pod` | pod | `image: android-emulator` | Android 14 emulator (/dev/kvm) + adb/appium |
-| `check-openclaw-pod` / `check-openclaw-full-pod` / `check-openclaw-desktop-pod` | pod | `image: openclaw*` | minimal / maximal / streamed-desktop headless gateway |
| `check-cachyos-gpu-vm` / `check-selkies-labwc-nvidia-vm` / `check-selkies-kde-nvidia-vm` | vm | `from: cachyos-gpu-vm` | VFIO GPU-passthrough + nested selkies NVENC streaming (`requires_exclusive: [nvidia-gpu]` — serialized) |
| `check-arch-pacstrap-vm` / `check-arch-vm` / `check-cachyos-vm` / `check-debian-debootstrap-vm` / `check-ubuntu-debootstrap-vm` / `check-fedora-vm` | vm | `from: -vm` | the distro bootstrap-VM beds (pacstrap / debootstrap) |
| `check-cross-pod-cdp` / `check-cross-vm-http` / `check-cross-local-http` / `check-cross-local-driver` | pod/vm/local | — | cross-deployment probing (pod→pod, host→pod/VM) |
@@ -223,7 +222,7 @@ hardcodes that name — it flows from the `iterate.sandbox:` field through
`ResolveIterateSandbox`, and prompts reference it via the `${TARGET_NAME}`
substitution token. **The sandbox is an operator-provisioned per-host
deploy** — the runner restarts-but-never-creates it: provision once with
-`charly fleet add check-sandbox --disposable` + `charly start
+`charly deploy add check-sandbox --disposable` + `charly start
check-sandbox` (the ref must provide charly + nested podman + the configured
AI CLI; the per-host overlay never ships with the repo). On a host without
the entry, `charly check run ` fails fast with exactly that
@@ -257,7 +256,7 @@ construction.
`check-pod` ~110s idle but **842s measured under a concurrent roster** (one build →
deploy → check → fresh-update → teardown cycle covering all four mechanisms) ·
`check-local-vm` ~45s · `check-sidecar-pod` ~180–290s · `check-k3s-vm` ~5–7 min ·
-`check-openclaw-full-pod` ~1414s · the heavy feature beds
+the heavy feature beds
(`check-sway-browser-vnc-pod` ~2477s ≈ 41 min incl. image build) longer.
**These are load-dependent, and the idle figures mislead.** A bed's own newest
@@ -279,7 +278,7 @@ libvirt domain / emulator it spawns outlives a single turn. (1) **Launch it as a
harness-tracked background task** (`run_in_background`) — never foreground (the
Bash tool's `timeout`, 120s default / 600s max, kills the call mid-`vm-create`,
orphaning the domain) and
-never a sleep/poll loop (the R4 bandaid). (2) **Let the completion notification
+never a sleep/poll loop (the R3 bandaid). (2) **Let the completion notification
drive the next step** — the harness re-invokes the launching session when the run
exits, so the launcher must survive to completion to be notified: the persistent
main session does; an ephemeral sub-agent (returns synchronously) and an idle
@@ -303,10 +302,15 @@ use `--if-exists`, which is idempotent and still cleans managed metadata. Confir
identity with `charly vm list`; see [`/charly-vm:vm`](/recipes/vm/vm/) "`charly vm destroy` — the DEPLOY name keys the
domain" and [`/charly-internals:agents`](/recipes/internals/agents/) "The binding rule".
-Final target and member teardown are recorded acceptance steps. In particular, a targetless group
-records `cleanup-members`; a failed members-down operation fails the bed rather than leaving cleanup
-to inference from a later host inventory. The group fresh-rebuild transition likewise records and
-requires `rebuild-members-down` before bringing the members back up.
+Final target and member teardown happen on the SUCCESS tail as recorded acceptance steps (a
+targetless group records `cleanup-members`), **and** on every unwinding failure path through the
+bed runner's ONE venue-teardown owner: a failed run disposes of its deployed target and its members
+unless retention was explicitly requested (`--keep-on-failure`, `--keep`/`--keep-venue`,
+`keep_venue:`, or the root's `ephemeral.keep_on_failure:`). On the success path a failed
+members-down operation fails the bed; on the failure path it is reported as a warning so the
+original step error survives. The SIGNAL path cannot tear down at all (nothing unwinds), so
+`charly check stop` names the scoped destroy verb instead. The group fresh-rebuild transition
+likewise records and requires `rebuild-members-down` before bringing the members back up.
### Golden snapshots: stop the keeper after capture
@@ -461,7 +465,7 @@ explicit pin, so the guard never fires inside a bed.
**Which verb/bed proves what (the project rulebook R7):** `charly check box` passes on
zero-content stages too — it is not a substitute for the generated-artifact
-checks (R8). For the R10 gate, pick the disposable bed whose kind matches
+checks (R7). For the R10 gate, pick the disposable bed whose kind matches
what you changed (`check-pod` for the combined box/candy/pod/deploy-target
mechanism, `check-local`, `check-k3s-vm`, or a feature bed). `charly check run`
on an `iterate:` bed is the multi-hour AI-iteration benchmark, never a quick
@@ -491,11 +495,11 @@ The project rulebook R10 carries the mandate; this matrix is the authoritative d
| Change class | Pre-flight | The R10 gate | Tier on a clean pass | Explicitly NOT required |
|---|---|---|---|---|
-| **Documentation-only change class** — `*.md` (`AGENTS.md` / `CLAUDE.md`, the marketplace corpus's `**/SKILL.md`, READMEs, CHANGELOG), comment-only code edits, or a submodule pointer bump to an all-documentation submodule commit; zero behavior change | markdown integrity, link checks | The non-runtime standards: adversarial consistency review, the R5 grep self-test, cross-reference validation, and command-safety gates | `documentation reviewed` | ANY bed run or image build — beds cannot fail on prose |
+| **Documentation-only change class** — `*.md` (`AGENTS.md`, the marketplace corpus's `**/SKILL.md`, READMEs, CHANGELOG), comment-only code edits, or a submodule pointer bump to an all-documentation submodule commit; zero behavior change | markdown integrity, link checks | The non-runtime standards: adversarial consistency review, the R5 grep self-test, cross-reference validation, and command-safety gates | `documentation reviewed` | ANY bed run or image build — beds cannot fail on prose |
| **Hook / workflow scripts** — `.claude/hooks/*.sh`, `.claude/workflows/*.js` | `bash -n` / async-body parse | Execute the changed script live: run the hook directly (paste its output); a workflow whose control flow changed runs against ONE bed matching the change. Prompt-string-only workflow edits: parse + the non-runtime standards | `fully tested and validated` | The full bed fan-out |
| **Harness project configuration** — `AGENTS.md`, `.codex/**`, repo-native `.agents/**`, or an executable that provisions or validates those surfaces | Parse the changed configuration; run the project profile and static-validator checks; verify exact gitlink and linked-worktree provenance | Run the final-tree repository harness gate: execute each changed validator/provisioner directly, verify only the gitlinks dispatched by the change class at their recorded revisions, and run the committed developer-profile checks. A Git-provisioning change also proves its canonical-object reference and exact-gitlink behavior in a dedicated linked worktree. Record the exact approved commands and active managed sandbox available for this run. This proves repository-controlled behavior; never claim that it proves settings loaded by a newly launched harness process. The fresh validator independently decides whether the final-tree delta additionally requires a Charly R10 bed and runs it when required. | `fully tested and validated` | A forced restart/new session, alternate home/cache/workspace, or unrelated VM/container roster that cannot exercise repository configuration |
| **`charly` Go code** | `go test ./...` + `go vet` + the R9 source build `scripts/bootstrap-charly.sh` (R9 freshness + `charly version` check on the worktree binary) | `charly check run ` for EACH bed whose kind matches a touched code path: box/candy/pod/deploy-target mechanism → `check-pod`; `target: local` → `check-local`; VM / kubernetes → `check-k3s-vm`; a feature surface → its feature bed. Cross-cutting loader / resolver / IR / unified-schema changes → run the matching beds as one `kind:check-roster` roster (`charly check run `, its `lanes` pool overlapping every bed) — in-spec for that class, not a scope override; a roster run that does not include every matching bed is a partial roster, never a green gate | `fully tested and validated` | Beds whose substrate the change cannot reach |
-| **Contract / library module** — a Go module with NO `charly.yml` project, NO `main`/deployable target and therefore NO `disposable: true` entity (the `github.com/opencharly/spec` + `github.com/opencharly/sdk` contract modules; any standalone library repo) | none beyond the suite | Run the repo's OWN committed module CI (`.github/workflows/ci.yml`: `go build ./...` + `go test ./...` + `gofmt -l .` + `golangci-lint run ./...`) GREEN on the PR head — the module suite IS the gate, because no bed CAN exist (a bed that cannot fail on the change proves nothing). The suite must genuinely EXECUTE the changed paths (a test that would FAIL without the change), not merely compile them. The `no disposable target` premise is a negative claim: prove it with executed output (`git ls-files \| grep -c 'charly.yml'` → 0; `git grep -c 'disposable: true'` → no match). A change whose behaviour is observable only at a CONSUMER's live boundary names the consumer PR running that bed — the bed rides the consumer change (producer→consumer order, B6), never the library PR | `fully tested and validated` | A `charly check run` bed in the module repo — none can exist there; the consumer boundary bed belongs to the consumer PR |
+| **Contract / library module** — a Go module with NO `main`/deployable target and therefore NO `disposable: true` entity (the `github.com/opencharly/spec` + `github.com/opencharly/sdk` contract modules; any standalone library repo). Shipping no `charly.yml` is NOT the discriminator: `spec` carries one holding only its `repo:`/`task:` maintenance manifest, and a task-only manifest is no bed venue | none beyond the suite | Run the repo's OWN committed module CI (`.github/workflows/ci.yml`: `go build ./...` + `go test ./...` + `gofmt -l .` + `golangci-lint run ./...`) GREEN on the PR head — the module suite IS the gate, because no bed CAN exist (a bed that cannot fail on the change proves nothing). The suite must genuinely EXECUTE the changed paths (a test that would FAIL without the change), not merely compile them. The `no disposable target` premise is a negative claim: prove it with executed output on the PR head — `git grep -nE '^[[:space:]]*disposable:' -- '*.yml' '*.yaml'` returns no match, and the root `charly.yml` is absent or declares no `box:`/`deploy:`/`check:` node. Key the grep to the ANCHORED key at line start: the bare string also sits in CHANGELOG prose and in Go string literals, and a bare `grep 'charly.yml'` also matches the `*.charly.yml.golden` `workflowkit` fixtures — either form makes the premise false-negative on `sdk`. A change whose behaviour is observable only at a CONSUMER's live boundary names the consumer PR running that bed — the bed rides the consumer change (producer→consumer order, B6), never the library PR | `fully tested and validated` | A `charly check run` bed in the module repo — none can exist there; the consumer boundary bed belongs to the consumer PR |
| **Candy / box / pod / vm / kubernetes / local / android config** | `charly box validate` | Build + run a bed that composes the changed entity (a candy edit → a bed whose image stacks that candy); when no bed composes it, the R7 sequence on a disposable deploy: build → `charly check box` → deploy → `charly check live` → fresh `charly update` | `fully tested and validated` | Beds that do not compose the changed entity |
| **`iterate:` / ai check config** | `charly box validate` | The affected `iterate:` bed run as specified (see "Flag discipline") | `fully tested and validated` | Unrelated beds |
@@ -556,9 +560,9 @@ These are the standards referenced in the project rulebook's AI attribution tier
0. **Prove every high-risk assumption before you edit (RDD — Risk Driven Development)** — the proactive bookend to Standard 11's fresh-rebuild gate. Low-risk orientation ("what does layer X do") is a skill lookup (R0, zero risk); every high-risk assumption — including any a skill or the code merely *asserts*, and above all whether this layer composition at its latest available versions builds / deploys / runs together — is proven on a `disposable: true` bed first (`charly check` it). Never accept docs or code as ground truth for a high-risk decision; if the bed disagrees with a skill, the skill is stale — fix it. Standard 0 (validate forward, riskiest-first) and Standard 11 (re-verify on a fresh rebuild) are the two ends of the same loop.
1. **Build a real artifact** (R7) — `charly box build ` / `go build` / `charly vm build `. Not just `go test`. Not just `charly box generate`.
-2. **Verify the emitted artifact's content** (R8) — `grep -c supervisord-conf .build//Containerfile` for any image that uses supervisord; `charly check libvirt domain-xml ` for a VM.
-3. **Verify critical OCI / capability labels post-build** (R8) — `charly box labels --format init` (or any `ai.opencharly.` shorthand) prints the built ref's label and exits non-zero when absent; `charly box labels ` lists the whole capability contract ([`/charly-internals:capabilities`](/recipes/internals/capabilities/)). Empty / missing label → the detection path silently returned nil → regression.
-4. **Deploy to a disposable target** (R10) — never experiment on a resource that doesn't carry `disposable: true`. If no suitable disposable target exists, create one first (`charly fleet add --disposable` or mark a VM `disposable: true` in `charly.yml` and `charly vm create`). The setup is part of the task. On a disposable target: `charly update ` (unattended). On anything else: confirm with the user before any irreversible destroy — except preempting a declared-`preemptible:` holder, which is standing-authorized (reversible: graceful stop + guaranteed restore).
+2. **Verify the emitted artifact's content** (R7) — `grep -c supervisord-conf .build//Containerfile` for any image that uses supervisord; `charly check libvirt domain-xml ` for a VM.
+3. **Verify critical OCI / capability labels post-build** (R7) — `charly box labels --format init` (or any `ai.opencharly.` shorthand) prints the built ref's label and exits non-zero when absent; `charly box labels ` lists the whole capability contract ([`/charly-internals:capabilities`](/recipes/internals/capabilities/)). Empty / missing label → the detection path silently returned nil → regression.
+4. **Deploy to a disposable target** (R10) — never experiment on a resource that doesn't carry `disposable: true`. If no suitable disposable target exists, create one first (`charly deploy add --disposable` or mark a VM `disposable: true` in `charly.yml` and `charly vm create`). The setup is part of the task. On a disposable target: `charly update ` (unattended). On anything else: confirm with the user before any irreversible destroy — except preempting a declared-`preemptible:` holder, which is standing-authorized (reversible: graceful stop + guaranteed restore).
5. **Target must reach steady-state** — `charly status ` → `running`; `charly check libvirt info ` → state `running`; SPICE socket file exists and accepts a handshake. If the service start-limit is hit, the container is crashing — read `charly logs ` and reproduce in a disposable shell (`charly shell `, running the service command manually).
6. **Run the declarative test suite** — `charly check live ` full three-section pass against the live container (or `--uri` / `--host` remote equivalent for a remote target).
7. **Verify the deployed binary is the one you built** (R9) — `charly version` on the target matches the expected CalVer, and `charly status ` (detail `Image ref:` line; JSON `image_ref`) shows the running container's image ref carries THIS build's CalVer tag, not the prior one. Source-only changes (Syncthing, git push) do not update the deployed binary; you must build AND deploy on the target host.
@@ -574,6 +578,49 @@ These are the standards referenced in the project rulebook's AI attribution tier
than inferring it from a PASS. Same failure family as invoking a stale
`/usr/bin/charly`: "the binary under test must be the one invoked" applies to
every binary in the chain.
+
+ **The compiled-in mirror of this standard.** The remedy above is stated for an
+ OUT-OF-PROCESS verb and does not generalise: a candy named in charly's
+ `compiled_plugins:` is emitted by `pluginsgen` as a `registerCompiledPlugin(...)`
+ call and resolved as a `require` pin, so the plugin is LINKED INTO `bin/charly` —
+ and no environment variable re-points a linked symbol. `CHARLY_PLUGIN_DIR` is read
+ only by the out-of-process loader (`charly/plugin_loader.go`) and is never
+ consulted for a compiled-in plugin; `CHARLY_REPO_OVERRIDE` re-points the candy
+ RESOLVER and the local plugin source, not the Go module graph. A bed run on a
+ stock binary therefore cannot fail on an unmerged compiled-in plugin AT ALL — the
+ same "proves nothing about your rebuild" failure this standard exists to catch,
+ and harder to notice, because the plugin genuinely is in the binary and the run
+ genuinely is a bed run.
+
+ The sanctioned override is
+ `scripts/bootstrap-charly.sh --dev-plugin =`
+ (repeatable). `pluginsgen` re-points its ENTIRE read of that repo at the local
+ checkout and emits a SEPARATE `go.work.dev` selected only by an explicit `GOWORK=`,
+ so the committed `go.work`/`go.work.sum` stay byte-identical (a workspace's sum
+ file is keyed to its own path, so a dev build's lock lands in `go.work.dev.sum`);
+ a later run WITHOUT the flag deletes `go.work.dev`, so a stale dev workspace can
+ never resolve a local tree for a plain build. The build prints a `DEV BUILD`
+ banner on stderr and refuses `--install`. Before compilation the generator
+ validates both halves: the name must appear in `compiled_plugins:`, and the
+ checkout's `candy//go.mod` must declare module
+ `github.com/opencharly//candy/` — the path the generated
+ registration imports.
+
+ Then assert the changed path EXECUTED rather than inferring it from a PASS — the
+ discipline above, against two traps that have each shipped a false green:
+
+ - `go version -m ` reports the plugin's module as `(devel)` in a
+ `--dev-plugin` build and as the module-proxy tag in a release build. That is the
+ cheap discriminator between the two binaries, and it is necessary but NOT
+ sufficient: it proves the module GRAPH, not that the changed arm ran.
+ - Set-difference the two binaries' plugin symbol tables (`go tool nm`) — never
+ `grep` for the changed name with a package-anchored pattern (`.`).
+ A method's symbol carries its receiver between the package and the name —
+ `.(*T).Method`, e.g.
+ `github.com/opencharly/plugin-check/candy/plugin-check.(*bedRunState).snapshotStep`
+ — so an anchored pattern matches every plain function and no method at all: a
+ change confined to method bodies reads as absent, and a real regression reads
+ as clean. (An unanchored `` grep does find them — the trap is the anchor.)
9. **Verify runtime deps are installed via package management** (R9) — on the host: `charly doctor` (dependency status), or the host package manager directly (`rpm -q ` / `pacman -Q ` — host packages are not charly-managed resources, so the package manager IS their interface); inside a container: `charly cmd 'rpm -q '`. Manual installs do not count — they won't survive a fresh install on a synced host. Every runtime dep must live in `setup.sh` + the charly candy's `packaging:` section (`candy/charly/charly.yml`).
10. **Leave the target healthy, not paused/errored** — the final `charly status` (and `charly check libvirt info ` for a VM) is healthy. If the target is in a broken state during exploration, `charly update` it back to the committed config before continuing — never layer experiments on broken state.
11. **Re-verify on a fresh rebuild after committing the source-level fix** (R10) — `charly update ` one more time from clean, with the new source applied. Run standards 1–10 again against this fresh rebuild. **This is the acceptance gate.** A fix that works on a hand-patched target but not on a fresh rebuild is a regression waiting for the next unrelated rebuild to wipe your patch. Paste both the exploratory-pass output and the fresh-rebuild-pass output into the conversation — the user sees both.
@@ -585,7 +632,7 @@ These are the standards referenced in the project rulebook's AI attribution tier
`DeployExecutor` chain that lands probes inside the leaf's actual
venue — `command: id` for a pod-in-VM leaf returns the inner pod's user, not
the parent VM's. Live-check and AI-iteration-scoring chain construction go
-through the same code path `charly fleet add` uses.
+through the same code path `charly deploy add` uses.
For deeply-nested paths, each segment adds one hop:
```bash
@@ -594,7 +641,7 @@ charly check live check-vm.inner # → SSH + podman exec ch
charly check live check-vm.inner.deeper # → SSH + 2× podman exec (3 hops)
```
-Same chain primitive (`NestedExecutor`) used everywhere — `charly fleet
+Same chain primitive (`NestedExecutor`) used everywhere — `charly deploy
add`, `charly check live`, `charly check run` (AI iteration scoring).
**Exception — a pod leaf nested in a VM delegates its check to the guest.** Because
@@ -618,7 +665,7 @@ probing") for the symmetric authoring surface in a bed's plan (venue-from-positi
A green `go test ./...` run does not prove a cutover done — build, deploy, run, and test the live target every time. A post-`charly update` re-test must run against the freshly deployed container, not the prior one still resident from before the update (an `is not running` error on the new container means the update broke it, not that "tests pass in aggregate"). A service-start failure (`A dependency job for X failed` + immediate exit) is a real error to read (`charly logs`) and reproduce (`charly shell`), never a presumed transient. `disposable: true` is the sole destroy authorization — a `lifecycle: dev` tag and "it's a dev box" framing grant none; everything else needs user confirmation regardless of hostname. A fix verified only on a target hand-patched over an afternoon is unverified; re-run `charly update` from clean and re-verify before claiming success. A plan that said "one PR" gets no invented Phase 2. Docs and code are not proof for a high-risk assumption — prove it on a disposable bed first (Standard 0); conversely, don't bed-test a low-risk fact a skill already states — that is wasted effort. Risk, not documentation status, is the trigger.
-If the container needs state that's only available in deploy (volumes, env, tunnel), author the step at `context: [deploy]`. If it needs something at build only (binary path, package presence), author at `context: [build]`. Both contexts must pass for the cutover to be real.
+If the step needs state that's only available on a live deployment (volumes, env, tunnel), author it at `context: [runtime]` — the context `charly check live` activates. If it needs something at build only (binary path, package presence), author at `context: [build]`. Note that those two ARE the check engine's contexts: a `check:` step scoped `context: [deploy]` is active in NEITHER mode and never runs (`opencharly/opencharly#413`, `opencharly/plugin-check#111`); `deploy` keeps its meaning on a `run:` step, where it names the install timeline. Both active contexts must pass for the cutover to be real.
**Confidence tier mapping:** The "fully tested and validated" confidence level in the project rulebook's AI-attribution table requires all the standards met — including the fresh-rebuild re-verification. Anything short of that ships at a lower confidence tier.
diff --git a/src/content/docs/recipes/check/check/cross-deployment-probing.md b/src/content/docs/recipes/check/check/cross-deployment-probing.md
index 8980868d..2afa9fb1 100644
--- a/src/content/docs/recipes/check/check/cross-deployment-probing.md
+++ b/src/content/docs/recipes/check/check/cross-deployment-probing.md
@@ -70,7 +70,7 @@ Two pieces compose it:
position: a step in the `plan:` of member `M` runs on `M` (bare name), a step in the
`plan:` of a nested child `C` of parent path `P` runs on `P.C` (dotted). There is no authored
`on:`/`pod:` step field — both are retired; tree position is the sole source of
- venue (`flattenFleetVenues` stamps it at load time). A `cdp:`/`vnc:`/`mcp:` step
+ venue (`FlattenVenuesByPosition` stamps it at load time). A `cdp:`/`vnc:`/`mcp:` step
placed under a driver member connects to that member's CDP/VNC/MCP endpoint; a
`command:` step runs in that member's venue. Members are brought up by the same
deploy verbs and are never check-live'd (instruments, not subjects). A GROUP
diff --git a/src/content/docs/recipes/check/check/live-probe-verbs.md b/src/content/docs/recipes/check/check/live-probe-verbs.md
index 6976df5d..62113f77 100644
--- a/src/content/docs/recipes/check/check/live-probe-verbs.md
+++ b/src/content/docs/recipes/check/check/live-probe-verbs.md
@@ -219,13 +219,13 @@ auto-picks.
plan:
- check: the MCP server answers a ping
id: mcp-ping
- context: [deploy]
+ context: [runtime]
mcp: ping
timeout: 10s # optional; default 30s
- check: the MCP server lists its notebook tools
id: mcp-list-tools
- context: [deploy]
+ context: [runtime]
mcp: list-tools
stdout:
- contains: insert_cell # plaintext "name\tdescription" per line
@@ -233,7 +233,7 @@ plan:
- check: calling list_notebooks succeeds (no IsError)
id: mcp-call-tool
- context: [deploy]
+ context: [runtime]
mcp:
method: call
tool: list_notebooks # required
@@ -242,7 +242,7 @@ plan:
- check: reading a resource returns a non-empty body
id: mcp-read-resource
- context: [deploy]
+ context: [runtime]
mcp:
method: read
uri: file:///tmp/example.txt
diff --git a/src/content/docs/recipes/check/check/verb-catalog.md b/src/content/docs/recipes/check/check/verb-catalog.md
index c771f2bc..fb062ed9 100644
--- a/src/content/docs/recipes/check/check/verb-catalog.md
+++ b/src/content/docs/recipes/check/check/verb-catalog.md
@@ -140,7 +140,7 @@ schema; a scalar value is the verb's primary-field shorthand (`file: /x`,
| `skip: true` | Always skip this check (reported but doesn't fail the run). |
| `exclude_distro: [, ...]` | Skip the check when any of the image's `distro:` tags matches an entry. Use for probes that only apply on some distros (e.g. `file: /usr/bin/fastfetch` is valid on Fedora/Arch/Debian but fastfetch is dropped from Ubuntu 24.04's noble main). Matched against the image's full distro list (`["ubuntu:24.04", "ubuntu", "debian"]`), so either `ubuntu:24.04` or `ubuntu` matches. See `charly/checkspec.go:Op.ExcludeDistros` and `charly/checkrun.go:runOne`. |
| `timeout: "5s"` | Per-check timeout (http, addr). |
-| `context: [build\|deploy\|runtime]` | Which contexts the step runs in (a list). Build steps run in `charly check box`; deploy/runtime steps need a live deployment. |
+| `context: [build\|deploy\|runtime]` | Which contexts the step runs in (a list). **The check engine activates exactly two of them**: `build` under `charly check box`, `runtime` under `charly check live` (`sdk/checkkit/checkkit.go:37`; `sdk/kit/runner.go:225`). An unscoped step with a plugin-envelope verb runs in BOTH. `deploy` is **not** a mode any check runner has: on a `check:`/`agent-check:` step it is a no-op in both modes (the step is skipped, so a plan relying on it asserts nothing — `opencharly/opencharly#413`, `opencharly/plugin-check#111`); it keeps its meaning on a `run:` step, where it names the install timeline. Use `[runtime]` for a live-only assertion and `[build]` for a build-artifact assertion. |
| `eventually: "30s"` | POLL until the check passes, re-running it every `retry_interval` until this deadline. The FIRST pass wins and returns immediately; on failure the LAST attempt is reported, annotated `(after N attempts over D)`. Absent means the check runs exactly once. |
| `retry_interval: "2s"` | Poll interval for `eventually:`. Default 1s. Clamped if it exceeds the deadline. |
@@ -190,7 +190,7 @@ verb. One code per test. See [`references/authoring-gotchas.md`](/recipes/check/
|---------|-------------|--------------|
| `candy` | `plan:` steps in the candy body, `candy//charly.yml` (build context) | `charly check box` + `charly check live` |
| `box` | `plan:` steps in the box body, `charly.yml` (build context) | `charly check box` + `charly check live` |
-| `deploy` | a `plan:` step with `context: [deploy]`, or a local `charly.yml` overlay step | `charly check live ` only (deploy-context steps need a running deployment with port mappings, volumes, and resolved runtime variables) |
+| `deploy` | a `plan:` step in the deployment node's own `plan:` (a local `charly.yml` overlay step), or a step authored `context: [runtime]` | `charly check live ` only (these steps need a running deployment with port mappings, volumes, and resolved runtime variables). **Not** a step scoped `context: [deploy]`: the engine activates `build` (box mode) and `runtime` (live mode) only, so a `check:` step carrying `[deploy]` is skipped in both modes and asserts nothing (`opencharly/opencharly#413`) |
The `ai.opencharly.description` label carries the collected plan steps from all three
sections with `origin:` annotations (`candy:`, `box:`, `deploy-default`,
diff --git a/src/content/docs/recipes/check/console-automation.md b/src/content/docs/recipes/check/console-automation.md
index 26e481b2..3b5a9219 100644
--- a/src/content/docs/recipes/check/console-automation.md
+++ b/src/content/docs/recipes/check/console-automation.md
@@ -55,7 +55,7 @@ ECHOES the typed line, so waiting for the command text — or for the marker as
SUBSTRING — passes the instant the command is typed, BEFORE it runs, certifying
completion that never happened. So the engine types `command; echo `
and waits for a screen LINE whose trimmed text EQUALS the marker: only the shell's
-OWN output produces that. It is a condition poll, never a sleep (R4).
+OWN output produces that. It is a condition poll, never a sleep (R3).
## luks-unlock — the encrypted-install first boot
@@ -79,7 +79,7 @@ single anchors with explicit DATA:
node id; the OBSERVED outcome selects the branch. No transition → `next:`.
- **while loop** — a transition pointing BACK to an earlier node is a loop, bounded
by `flow_max_loops` (per node) and `flow_max_steps` (whole flow). A condition
- that never becomes true FAILS naming `max_loops` — the R4-safe "repeat until".
+ that never becomes true FAILS naming `max_loops` — the R3-safe "repeat until".
A node action may be a raw input (`key`/`combo`/`text`) or a shell `command:`
(run in the open terminal, output OCR-read). The flow validates WITHOUT a device.
diff --git a/src/content/docs/recipes/check/crabbox.md b/src/content/docs/recipes/check/crabbox.md
index 89096ea8..73550da9 100644
--- a/src/content/docs/recipes/check/crabbox.md
+++ b/src/content/docs/recipes/check/crabbox.md
@@ -14,7 +14,7 @@ description: "the `crabbox:` check verb — probe the Crabbox CLI or its local c
`crabbox:` probes the [Crabbox](https://github.com/openclaw/crabbox) CLI
and its optional local Node.js/PostgreSQL coordinator, served
out-of-process over go-plugin gRPC by `opencharly/plugin-crabbox`.
-Install the CLI with [`/charly-tools:crabbox`](/recipes/tools/crabbox/) (opencharly/layer-crabbox)
+Install the CLI with [`/charly-tools:crabbox-cli`](/recipes/tools/crabbox-cli/) (opencharly/layer-crabbox)
and the coordinator with [`/charly-crabbox:crabbox-coordinator`](/recipes/crabbox/crabbox-coordinator/)
(opencharly/pod-crabbox).
@@ -34,7 +34,14 @@ and the coordinator with [`/charly-crabbox:crabbox-coordinator`](/recipes/crabbo
|---|---|
| In-venue CLI — credential-free | `version` (`crabbox --version`) `providers` (`crabbox providers`) `config` (`crabbox config show`) `doctor` (`crabbox doctor` — needs NO broker configured, or a REACHABLE one) |
| Coordinator HTTP probe (host-side, port 8080) | `health` (`/v1/health`) `ready` (`/v1/ready`) |
-| In-venue CLI — broker-backed (requires `crabbox login` in the venue) | `leases` (`crabbox status --all`) `usage` (`crabbox usage`) `events` (`crabbox events `) `logs` (`crabbox logs `) |
+| In-venue CLI — broker-backed (requires `crabbox login` in the venue) | `leases` (`crabbox list` — the ID/NAME/STATE/TYPE/IP inventory) `usage` (`crabbox usage`) `events` (`crabbox events `) `logs` (`crabbox logs `) |
+
+Per-lease state is `crabbox status ` — *"Show lease state; add
+`--wait` to block until ready"*. It is deliberately NOT this verb's `leases`
+method: `status` is not an enumerator, and it has **no `--all` flag** (measured
+on the pinned v0.78.0: `crabbox status --all` exits *"flag provided but not
+defined: -all"*), so the earlier prose that documented `leases` as
+`crabbox status --all` named a command that cannot run.
All methods are read-only — there are no mutating methods in this first cut.
@@ -47,6 +54,7 @@ All methods are read-only — there are no mutating methods in this first cut.
| `timeout` | HTTP probe timeout (default 10s) |
| `json_path` | dotted path into the response; stdout becomes that value |
| `run_id` | run identifier for the `events`/`logs` methods |
+| `provider` | the provider to select for the methods whose CLI command DEFINES `--provider` (`doctor`, `leases`) |
## Scalar shorthand
@@ -68,3 +76,21 @@ broker — the venue must hold a prior `crabbox login` (shared-token:
`crabbox login --url http://127.0.0.1:8080 --token-stdin`). `doctor` exits
nonzero when a broker is configured but unreachable — gate it on
coordinator readiness first.
+
+## Selecting a provider
+
+`provider:` is passed through as `--provider ` for the two methods whose CLI
+command defines the flag — `doctor` and `leases` (`crabbox list`) — and nowhere
+else, because attaching it to a command that does not define it is an argument
+error, not a selection. Measured on the pinned v0.78.0:
+
+```
+$ crabbox doctor --provider local-container -> ok provider-selection provider=local-container source=flag selected=true
+$ crabbox list --provider aws -> the ID/NAME/STATE/TYPE/IP inventory
+$ crabbox usage --provider aws -> flag provided but not defined: -provider
+$ crabbox providers --provider aws -> flag provided but not defined: -provider
+```
+
+Unset, the CLI keeps its own configured/ambient selection — which is what every
+step got before this input existed, so an authored step that does not name a
+provider behaves exactly as it did.
diff --git a/src/content/docs/recipes/check/dbus.md b/src/content/docs/recipes/check/dbus.md
index ee4590ee..dcd79529 100644
--- a/src/content/docs/recipes/check/dbus.md
+++ b/src/content/docs/recipes/check/dbus.md
@@ -36,12 +36,12 @@ when the step carries dbus-exclusive fields (`dest:`, `path:`, `member:`, `arg:`
`text:`) — those live INSIDE the `dbus:` map. The shared matchers (`stdout:`,
`stderr:`, `exit_status:`) and `description:` (the notify body, doubling as the step's
report label) stay siblings of the `dbus:` key. All `dbus:` steps are
-**deploy-context only** (they need a running session bus), so author them with
-`context: [deploy]`. See [`/charly-check:check`](/recipes/check/check/) for the full YAML shape. Example:
+**live-mode only** (they need a running session bus), so author them with
+`context: [runtime]`. See [`/charly-check:check`](/recipes/check/check/) for the full YAML shape. Example:
```yaml
- check: the notifications service is on the session bus
- context: [deploy]
+ context: [runtime]
dbus: list
stdout:
contains: org.freedesktop.Notifications
@@ -72,7 +72,7 @@ exit 0 (run them as `run:` steps). All steps are deploy-context only.
```yaml
- check: a desktop notification is delivered
- context: [deploy]
+ context: [runtime]
dbus:
method: notify
text: Build Complete # notification summary (dbus-exclusive, in the map)
@@ -86,7 +86,7 @@ appears via the running notification daemon (swaync or mako).
```yaml
- check: the notifications service reports its capabilities
- context: [deploy]
+ context: [runtime]
dbus:
method: call
dest: org.freedesktop.Notifications
@@ -101,7 +101,7 @@ Calls an arbitrary D-Bus method on the session bus and returns its reply.
```yaml
- check: the session bus has the expected services registered
- context: [deploy]
+ context: [runtime]
dbus: list
stdout:
contains: org.freedesktop.Notifications
@@ -113,7 +113,7 @@ Lists all registered services on the venue's session bus.
```yaml
- check: the notifications object exposes the Notify method
- context: [deploy]
+ context: [runtime]
dbus:
method: introspect
dest: org.freedesktop.Notifications
diff --git a/src/content/docs/recipes/check/jetkvm.md b/src/content/docs/recipes/check/jetkvm.md
index 033f4fff..bf536961 100644
--- a/src/content/docs/recipes/check/jetkvm.md
+++ b/src/content/docs/recipes/check/jetkvm.md
@@ -125,7 +125,7 @@ A ~15-screen wizard as a chain of `check:` steps pays that per screen; the drive
pays it once.
The recipe is DATA, supplied by a `kind: jetkvm` device entity — the plugin is
-installer-agnostic (R3), so one plugin drives any text console. Example:
+installer-agnostic (R2), so one plugin drives any text console. Example:
```yaml
omarchy-kvm:
diff --git a/src/content/docs/recipes/check/libvirt.md b/src/content/docs/recipes/check/libvirt.md
index ba6f691f..4369284a 100644
--- a/src/content/docs/recipes/check/libvirt.md
+++ b/src/content/docs/recipes/check/libvirt.md
@@ -47,7 +47,7 @@ siblings of the `libvirt:` key: the shared matchers (`stdout:`, `stderr:`,
`exit_status:`) AND the `target:` snapshot name (a genuinely shared `#Op` modifier
the `snapshot/*` methods read). A query/probe is a `check:` step; an action that
changes domain state (send-key, passwd, snapshot create/revert) is a `run:` step.
-**`context: [deploy]`** — the verb needs a running VM; under `charly check
+**`context: [runtime]`** — the verb needs a running VM; under `charly check
box` (no running VM) it skips. Run a candy's baked `libvirt:` steps against a
live VM deployment with `charly check live --filter libvirt`.
@@ -158,13 +158,13 @@ probe is a `check:` step, a state-changing action is a `run:` step. Run with
- check: the session lists the arch domain
id: libvirt-domains-listed
libvirt: list
- context: [deploy]
+ context: [runtime]
stdout:
contains: arch
- check: the domain reports a reachable guest agent
id: libvirt-agent-reachable
libvirt: info
- context: [deploy]
+ context: [runtime]
stdout:
contains: "Agent: true"
@@ -175,7 +175,7 @@ probe is a `check:` step, a state-changing action is a `run:` step. Run with
method: screenshot
artifact: /tmp/fb.png
artifact_not_uniform: true
- context: [deploy]
+ context: [runtime]
# Keyboard injection via libvirt (alternate path to the `spice: key` verb).
- run: switch to VT2 via a libvirt key chord
@@ -191,7 +191,7 @@ probe is a `check:` step, a state-changing action is a `run:` step. Run with
libvirt:
method: qmp
text: query-status
- context: [deploy]
+ context: [runtime]
stdout:
contains: running
@@ -201,7 +201,7 @@ probe is a `check:` step, a state-changing action is a `run:` step. Run with
libvirt:
method: guest/exec
command: uname -a
- context: [deploy]
+ context: [runtime]
stdout:
contains: Linux
diff --git a/src/content/docs/recipes/check/openclaw.md b/src/content/docs/recipes/check/openclaw.md
index 0b30c8a1..814fae88 100644
--- a/src/content/docs/recipes/check/openclaw.md
+++ b/src/content/docs/recipes/check/openclaw.md
@@ -1,6 +1,6 @@
---
title: "openclaw"
-description: "the `openclaw:` check verb — probe a running OpenClaw 2.0 gateway (health/ready/startup via the /healthz /readyz /startupz HTTP probe endpoints, status/models/channels/version via the in-venue CLI), served out-of-process by plugin-openclaw."
+description: "the `openclaw:` check verb — probe a running OpenClaw 2.0 gateway (health/ready/startup via the /healthz /readyz /startupz HTTP probe endpoints, status/models/channels/version via the in-venue CLI), served out-of-process by plugin-openclaw; the probe surface is verified against npm `openclaw@2026.9.8`."
---
@@ -13,8 +13,9 @@ description: "the `openclaw:` check verb — probe a running OpenClaw 2.0 gatewa
`openclaw:` probes a running [OpenClaw](https://openclaw.ai) 2.0 gateway,
served out-of-process over go-plugin gRPC by `opencharly/plugin-openclaw`.
+The probe surface below is verified against npm `openclaw@2026.9.8`.
Install the gateway itself with [`/charly-openclaw:openclaw`](/recipes/openclaw/openclaw/)
-(opencharly/pod-openclaw).
+(opencharly/layer-openclaw).
## Why it is not the `http:` verb
@@ -35,7 +36,7 @@ for what `http:` structurally cannot do:
| Group | Methods |
|---|---|
-| HTTP probes (host-side) | `health` (`/healthz` liveness) `ready` (`/readyz` readiness) `startup` (`/startupz` startup) |
+| HTTP probes (host-side) | `health` (`/healthz` — liveness only) `ready` (`/readyz` — readiness, and the endpoint that reports recorded terminal database failures) `startup` (`/startupz` — startup progress) |
| In-venue CLI | `status` (`openclaw status --all`) `models` (`openclaw models status`) `channels` (`openclaw channels status`) `version` (`openclaw --version`) |
All methods are read-only — there are no mutating methods in this first cut.
diff --git a/src/content/docs/recipes/check/record.md b/src/content/docs/recipes/check/record.md
index 1137750e..5f020595 100644
--- a/src/content/docs/recipes/check/record.md
+++ b/src/content/docs/recipes/check/record.md
@@ -59,8 +59,9 @@ record-exclusive fields (`record_name:`, `record_mode:`, `record_fps:`,
the `record:` map. Only the shared matchers (`stdout:`, `stderr:`, `exit_status:`) and
`context:`/`id:`/`timeout:` stay siblings. All `record:` steps need a running
container, so author them with `context: [runtime]` — the live phase, where the
-plugin resolves. (`context: [deploy]` is skipped in live mode; no context runs them
-in the box phase, where the plugin is unavailable.)
+plugin resolves. (`context: [deploy]` is a no-op in BOTH modes — the engine activates `build` for
+box and `runtime` for live — and no context value makes a live-only verb resolvable
+in the box phase, where its plugin is unavailable.)
```yaml
- check: a terminal recording starts
diff --git a/src/content/docs/recipes/check/spice.md b/src/content/docs/recipes/check/spice.md
index e447d7ba..b3e9564b 100644
--- a/src/content/docs/recipes/check/spice.md
+++ b/src/content/docs/recipes/check/spice.md
@@ -67,7 +67,7 @@ the step carries spice-exclusive fields — those live INSIDE the `spice:` map:
The artifact validators (`artifact_min_bytes:`, `artifact_min_dimensions:`,
`artifact_not_uniform:`) are spice-exclusive too, so they live inside the `spice:`
map; only the shared matchers (`stdout:`, `stderr:`, `exit_status:`) and
-`context:`/`timeout:`/`id:` stay siblings of the `spice:` key. **`context: [deploy]`**
+`context:`/`timeout:`/`id:` stay siblings of the `spice:` key. **`context: [runtime]`**
— the verb needs a running VM; under `charly check box` (no running VM) it
skips, and a SPICE-less deployment (e.g. a GPU desktop with no `` device) is reported N/A SKIP.
@@ -85,7 +85,7 @@ Example — each step is an ordered list item under the candy/box `plan:`:
- check: the SPICE server completes the handshake and enumerates channels
id: spice-handshake
spice: status
- context: [deploy]
+ context: [runtime]
stdout:
- contains: ok
- contains: "inputs: ready"
@@ -95,7 +95,7 @@ Example — each step is an ordered list item under the candy/box `plan:`:
method: screenshot
artifact: /tmp/spice-shot.png
artifact_not_uniform: true
- context: [deploy]
+ context: [runtime]
```
Input injection is authored as `run:` steps (each one mutates guest state) — a
@@ -116,7 +116,7 @@ console login sequence walks the form with `spice: key` / `spice: type` steps:
```yaml
- check: SPICE video recording starts on the VM display
id: spice-rec-start
- context: [deploy]
+ context: [runtime]
spice:
method: record
action: start
@@ -124,11 +124,11 @@ console login sequence walks the form with `spice: key` / `spice: type` steps:
fps: 5
- check: a desktop-visible action runs during the capture
id: spice-rec-drive
- context: [deploy]
+ context: [runtime]
command: "notify-send 'demo' 'capturing' 2>/dev/null || true; sleep 3"
- check: the recording stops and a real MJPEG stream lands on the HOST
id: spice-rec-stop
- context: [deploy]
+ context: [runtime]
spice:
method: record
action: stop
diff --git a/src/content/docs/recipes/check/transcode.md b/src/content/docs/recipes/check/transcode.md
new file mode 100644
index 00000000..2e2c5af8
--- /dev/null
+++ b/src/content/docs/recipes/check/transcode.md
@@ -0,0 +1,92 @@
+---
+title: "transcode"
+description: "The `transcode:` capture-evidence pipeline verb — transcode a captured MJPEG stream into an H.264 MP4 with the host's `ffmpeg`, served out-of-process by candy/plugin-media."
+---
+
+
+
+*Recipe card from the `charly-check` plugin (Commands — runtime CLI verbs).*
+
+# transcode — capture-evidence MJPEG to H.264 MP4
+
+The `transcode:` pipeline verb converts a captured MJPEG stream into an
+H.264/yuv420p MP4 with the **host's** `ffmpeg`. It is **NOT a host
+`charly check` subcommand** — it is a declarative pipeline word served
+out-of-process by its plugin (`candy/plugin-media`), parallel to the
+`record:`/`spice:` capture verbs. The MJPEG artifact a capture session
+flushes (e.g. `spice: record`) is already host-side, so the provider
+reaches only the local filesystem and the host `ffmpeg` — no
+reverse-channel venue machinery.
+
+**Served out-of-process — no host CLI subcommand.** The host dispatches
+the `transcode:` word through the provider registry exactly like a
+built-in (`ResolveVerb("transcode")` → the out-of-process gRPC provider
+→ `Provider.Invoke` with the full `Op`), executing in the **evidence
+phase** BEFORE venue teardown. Authoring is unchanged from a built-in
+verb: you write `transcode: mp4`, never `plugin: transcode`.
+
+## Authoring a `transcode:` step
+
+The word is authored in an instrument's `pipeline:` list. The scalar form
+is the `to` primary-field shorthand (`transcode: mp4` ==
+`transcode: {to: mp4}`); the exclusive fields live INSIDE the `transcode:`
+map.
+
+```yaml
+instrument:
+ - id: screen
+ phase: [live]
+ spice: {method: session, fps: 5}
+ pipeline:
+ - transcode: {to: mp4} # map form
+ - transcode: mp4 # scalar-sugar equal (primary field: to)
+```
+
+| Field | Meaning |
+|-------|---------|
+| `to` | container format; only `mp4` is supported (the default; empty/omitted means mp4) |
+| `artifact` | host path the MP4 is written to; empty → derived from the source (`.mp4`) |
+| `artifact_min_bytes` | post-transcode artifact-size assertion (the output MP4 must be at least N bytes) |
+| `artifact_not_uniform` | SOURCE-frame motion assertion, evaluated on the SOURCE MJPEG frames (pre-encode) |
+
+The shared matchers (`exit_status`/`stdout`/`stderr`) and `timeout` stay
+on core `#Op` and are self-evaluated by the provider against the ffmpeg
+run.
+
+## Behavior
+
+The provider runs the one canonical invocation (even-dimension scale +
+timescale pin for robustness):
+
+```
+ffmpeg -y -loglevel error -i -vf scale=trunc(iw/2)*2:trunc(ih/2)*2 \
+ -c:v libx264 -pix_fmt yuv420p -video_track_timescale 1000
+```
+
+**`artifact_not_uniform` runs on the SOURCE MJPEG frames (pre-encode),
+NEVER on the transcoded output** — exact hashes of x264-encoded frames
+are vacuous (QP noise). The provider splits the source stream into its
+constituent JPEGs, decodes a stream-wide sample, and requires at least
+two distinct frame signatures: identical frames FAIL honestly (a real
+capture shows motion), differing frames PASS.
+
+**DEPENDENCY:** requires host `ffmpeg` with `libx264` + `yuv420p`
+support.
+
+## Verification
+
+`plan:` `check:` runs the deterministic command form with the literal
+`true` target (the plugin envelope is internal only; a live transcode
+needs a session artifact). The live R10 witness is the cutover-A
+nested-capture bed that produces a session MJPEG and transcodes it.
+
+## Cross-References
+
+- [`/charly-check:check`](/recipes/check/check/) — the check-step surface the `transcode:` word is
+ authored through, and the capture-evidence pipeline it runs in.
+- [`/charly-check:record`](/recipes/check/record/) — the `record:` verb that produces the source
+ MJPEG artifact the transcode consumes.
+- [`/charly-check:spice`](/recipes/check/spice/) — `spice: {method: record}` flushes the source
+ MJPEG the transcode consumes.
+- [`/charly-internals:plugin`](/recipes/internals/plugin/) — the plugin/provider model (out-of-process
+ placement, the per-plugin CUE-schema contract).
diff --git a/src/content/docs/recipes/check/vnc.md b/src/content/docs/recipes/check/vnc.md
index aad8dd74..15d1077c 100644
--- a/src/content/docs/recipes/check/vnc.md
+++ b/src/content/docs/recipes/check/vnc.md
@@ -39,13 +39,13 @@ bare-method step (`vnc: status`), or the `method:` key of the `vnc:` map when th
carries vnc-exclusive fields (`x:`, `y:`, `button:`, `text:`, `key:`, `artifact:`,
`artifact_min_bytes:`, …) — those live INSIDE the `vnc:` map. Only the shared matchers
(`stdout:`, `stderr:`, `exit_status:`) and `context:`/`id:`/`timeout:` stay siblings.
-All `vnc:` steps are **deploy-context only** (they need a running deployment), so
-author them with `context: [deploy]`. See [`/charly-check:check`](/recipes/check/check/) for the full YAML
+All `vnc:` steps are **live-mode only** (they need a running deployment), so
+author them with `context: [runtime]`. See [`/charly-check:check`](/recipes/check/check/) for the full YAML
shape. Example:
```yaml
- check: a non-empty VNC framebuffer is captured
- context: [deploy]
+ context: [runtime]
vnc:
method: screenshot
artifact: /tmp/vnc.png
@@ -97,14 +97,14 @@ in `candy/plugin-vnc`. The host retains only the endpoint pre-resolution and
## Methods
-Each method below is a `vnc:` plan step authored with `context: [deploy]`; run a
+Each method below is a `vnc:` plan step authored with `context: [runtime]`; run a
candy's baked steps with `charly check live --filter vnc` (add
`-i ` for a specific instance).
### Screenshot
```yaml
- check: a non-empty VNC framebuffer is captured
- context: [deploy]
+ context: [runtime]
vnc:
method: screenshot
artifact: /tmp/desktop.png # host path for the captured PNG
@@ -162,7 +162,7 @@ Valid key names: Return, Escape, Tab, BackSpace, Delete, Home, End, Page_Up, Pag
### Status
```yaml
- check: the VNC server reports its desktop and resolution
- context: [deploy]
+ context: [runtime]
vnc: status
stdout:
contains: "1920x1080"
@@ -245,7 +245,7 @@ Some websites (notably Google sign-in) detect and block CDP-based input. VNC pro
```yaml
# 1. Locate via cdp: coords — reports both viewport and desktop coords for '#identifierId'
- check: the email field is located
- context: [deploy]
+ context: [runtime]
cdp:
method: coords
tab: "1"
@@ -287,7 +287,7 @@ desktop `x:`/`y:`:
```yaml
- check: the sync button is located
- context: [deploy]
+ context: [runtime]
cdp:
method: coords
tab: "1"
@@ -316,13 +316,13 @@ VNC screenshots work correctly on NVIDIA headless for images using `sway-desktop
Both the `vnc: screenshot` step and the `wl: screenshot` step (grim, always works) work on NVIDIA headless:
```yaml
- check: a non-empty VNC framebuffer is captured (works with pixman + DPMS fix)
- context: [deploy]
+ context: [runtime]
vnc:
method: screenshot
artifact: /tmp/out.png
artifact_min_bytes: 5000
- check: a non-empty Wayland screenshot is captured (grim)
- context: [deploy]
+ context: [runtime]
wl:
method: screenshot
artifact: /tmp/wl-out.png
diff --git a/src/content/docs/recipes/coder/arch-coder.md b/src/content/docs/recipes/coder/arch-coder.md
index c9166cfd..9be8f8ff 100644
--- a/src/content/docs/recipes/coder/arch-coder.md
+++ b/src/content/docs/recipes/coder/arch-coder.md
@@ -162,7 +162,6 @@ Conflicts with [`/charly-coder:fedora-coder`](/recipes/coder/fedora-coder/) / [`
- [`/charly-coder:debian-coder`](/recipes/coder/debian-coder/) — deb-family sibling on Debian 13.
- [`/charly-coder:ubuntu-coder`](/recipes/coder/ubuntu-coder/) — deb-family sibling on Ubuntu 24.04 (adopt mode).
- [`/charly-coder:charly-arch`](/recipes/coder/charly-arch/) — slimmer Arch alternative with just the charly toolchain (no AI CLIs or DevOps tooling).
-- [`/charly-openclaw:openclaw-desktop`](/recipes/openclaw/openclaw-desktop/) — adds a browser-streamed Wayland desktop (plus the openclaw gateway, AI CLIs, and a CPU ollama) with the same rootless charly toolchain.
## Related layers
diff --git a/src/content/docs/recipes/coder/charly-arch.md b/src/content/docs/recipes/coder/charly-arch.md
index ac39e4ce..5f0eead8 100644
--- a/src/content/docs/recipes/coder/charly-arch.md
+++ b/src/content/docs/recipes/coder/charly-arch.md
@@ -199,7 +199,6 @@ packages and scripts per distro.
- [`/charly-distros:arch`](/recipes/distros/arch/) — parent base image
- [`/charly-distros:charly-fedora`](/recipes/distros/charly-fedora/) — Fedora counterpart, same candies, same rootless posture
- [`/charly-coder:fedora-coder`](/recipes/coder/fedora-coder/) — kitchen-sink dev sibling (adds coding CLIs + DevOps)
-- [`/charly-openclaw:openclaw-desktop`](/recipes/openclaw/openclaw-desktop/) — streaming-desktop counterpart (charly toolchain + browser-accessible Wayland); shares the rootless-first posture
- [`/charly-distros:githubrunner`](/recipes/distros/githubrunner/) — self-hosted GitHub Actions runner; same uid=1000 posture
## Related Commands
diff --git a/src/content/docs/recipes/coder/charly-mcp.md b/src/content/docs/recipes/coder/charly-mcp.md
index d5deedb5..1a2f1fcd 100644
--- a/src/content/docs/recipes/coder/charly-mcp.md
+++ b/src/content/docs/recipes/coder/charly-mcp.md
@@ -123,8 +123,7 @@ Six deploy-scope tests ship with the candy:
| `mcp-charly-call-list-images` | MCP `call box.list.boxes` returns boxes — **proves the bind-mount OR auto-fallback is working** (matches "fedora" either way, since upstream opencharly/charly always has a `fedora` image) |
All `mcp:` checks pass `mcp_name: charly` so they stay unambiguous on
-boxes that also expose `jupyter` or `chrome-devtools` servers
-(e.g. [`/charly-openclaw:openclaw-desktop`](/recipes/openclaw/openclaw-desktop/)).
+boxes that also expose `jupyter` or `chrome-devtools` servers.
## Host networking caveat
diff --git a/src/content/docs/recipes/coder/codex.md b/src/content/docs/recipes/coder/codex.md
index 87ea6ebf..e0faf906 100644
--- a/src/content/docs/recipes/coder/codex.md
+++ b/src/content/docs/recipes/coder/codex.md
@@ -28,7 +28,6 @@ my-box:
## Used In Boxes
-- `openclaw-full` (via `openclaw-full` metalayer)
## Related Candies
- [`/charly-coder:nodejs`](/recipes/coder/nodejs/) — Required Node.js runtime parent dependency
diff --git a/src/content/docs/recipes/coder/direnv.md b/src/content/docs/recipes/coder/direnv.md
index eef5594e..b506e4d5 100644
--- a/src/content/docs/recipes/coder/direnv.md
+++ b/src/content/docs/recipes/coder/direnv.md
@@ -39,13 +39,16 @@ Provides the `direnv` binary AND the per-shell hook installation for automatic e
```yaml
shell:
init: |
- eval "$(direnv hook ${SHELL_NAME})" # bash/zsh/sh — POSIX-style
+ eval "$(direnv hook ${SHELL_NAME})" # bash/zsh — POSIX-style
fish:
init: |
direnv hook fish | source # fish — different syntax
+ sh: {} # sh opted OUT — direnv has no sh hook
```
-Container images get `/etc/profile.d/charly-direnv-.sh` and `/etc/fish/conf.d/charly-direnv.fish` emitted at `charly box build` time. `target: local` host deploys get a managed-block in `~/.bashrc` / `~/.zshrc` plus `~/.config/fish/conf.d/charly-direnv.fish` at `charly fleet add` time, only for shells the runtime probe finds. The fish hook lands in `~/.config/fish/conf.d/charly-direnv.fish` (its own conf.d drop-in), so it works without editing `~/.config/fish/config.fish`.
+The emitter is VENUE-SHAPED. Container images get `/etc/profile.d/charly-direnv-.sh` (bash/zsh only) and `/etc/fish/conf.d/charly-direnv.fish` emitted at `charly box build` time; each POSIX drop-in is runtime-guarded to its own shell because `/etc/profile` sources every `*.sh` in every POSIX login shell. `target: local` host and `target: vm` guest deploys instead get a managed block in `~/.bashrc` / `~/.zshrc` plus `~/.config/fish/conf.d/charly-direnv.fish` written at `charly deploy add` time — for every shell the `shell:` block defines, with no per-host shell probe. The fish hook lands in the per-user conf.d drop-in (its own file), so it works without editing `~/.config/fish/config.fish`.
+
+**The plan asserts each venue's OWN shape.** The baked drop-ins are `context: [build]` assertions (`charly box build` artifacts); the deploy venue's wiring is one `context: [runtime]` assertion — the live mode's context, because a `check:` step scoped `context: [deploy]` is active in NEITHER check mode (`opencharly/opencharly#413`). That runtime step branches on the venue marker (`/etc/profile.d/charly-direnv-bash.sh`): an image venue must carry the baked drop-ins, a host/VM venue must carry the managed `~/.bashrc` / `~/.zshrc` blocks and the per-user fish drop-in, and the `sh:{}` opt-out must hold at both. An unscoped image-shaped assertion run in a `local:` venue is exactly the defect this shape prevents (`opencharly/layer-direnv#7`).
The primary use case in OpenCharly is the `.secrets` workflow: `.envrc` calls `eval "$(charly secrets gpg env)"` which decrypts a GPG-encrypted `.secrets` file in memory and exports the variables — no plaintext on disk. No external `direnvrc` dependency needed.
diff --git a/src/content/docs/recipes/coder/fedora-coder.md b/src/content/docs/recipes/coder/fedora-coder.md
index 2407559a..59f3e696 100644
--- a/src/content/docs/recipes/coder/fedora-coder.md
+++ b/src/content/docs/recipes/coder/fedora-coder.md
@@ -11,8 +11,7 @@ description: "Kitchen-sink development box: coding + AI-coding CLIs + DevOps too
The everyday-development counterpart to [`/charly-distros:charly-fedora`](/recipes/distros/charly-fedora/): all the
coding + AI + DevOps tooling a developer needs in one box, no desktop,
-no streaming. Distinct from `openclaw-desktop` (which adds the
-browser-streamed Wayland desktop) — `fedora-coder` is headless and
+no streaming. `fedora-coder` is headless and
meant to be accessed via `ssh -p 2222` or `charly shell`.
> **Location:** lives in the **`opencharly/distro-fedora`** repo (git submodule at
@@ -85,8 +84,7 @@ normal `-p host:container` remapping (e.g. running `arch-coder` alongside
No `uid:` / `gid:` / `user:` / `security:` override — inherits
`1000/1000/user` from `defaults` and the candy-level security from
-[`/charly-distros:container-nesting`](/recipes/distros/container-nesting/). Rootless-first by design (shares this
-posture with [`/charly-openclaw:openclaw-desktop`](/recipes/openclaw/openclaw-desktop/)).
+[`/charly-distros:container-nesting`](/recipes/distros/container-nesting/). Rootless-first by design.
## Resolved security posture (OCI label)
@@ -134,8 +132,8 @@ includes `rpmfusion` explicitly.
`supervisord`, `language-runtimes`, and `uv` all use system python3 from RPM,
not the conda-forge pixi env, so none of them declares `require: python`.
System Python is available via `language-runtimes` (`python3-devel` +
-`python3-ramalama`). See the "Key Rules" note in the project rulebook (`AGENTS.md` / `CLAUDE.md`) ("don't declare
-defensive deps").
+`python3-ramalama`). Don't declare a defensive dependency: a `require:` that nothing in
+the candy uses is an unproven "to be safe" caution, not a need.
**`uv` is a direct-download binary** (no pixi involvement). Lives at
`/usr/local/bin/uv` and `/usr/local/bin/uvx`, extracted from the
@@ -251,7 +249,6 @@ All four produce the same daily-dev surface (sshd on 2222, charly-mcp on 18765,
## Related Boxes
-- [`/charly-openclaw:openclaw-desktop`](/recipes/openclaw/openclaw-desktop/) — sibling rootless-first power-user box; same security posture + container-nesting, but adds the streaming desktop. Prefer when you want browser-accessible GUI + dev tools.
- [`/charly-distros:charly-fedora`](/recipes/distros/charly-fedora/) — minimal charly toolchain (no coding CLIs, no DevOps), also uid=1000 with sudo.
- [`/charly-coder:charly-arch`](/recipes/coder/charly-arch/) — Arch Linux counterpart of charly-fedora.
- [`/charly-distros:githubrunner`](/recipes/distros/githubrunner/) — self-hosted GitHub Actions runner; same uid=1000 posture.
@@ -273,7 +270,7 @@ All four produce the same daily-dev surface (sshd on 2222, charly-mcp on 18765,
- Building, deploying, or troubleshooting the `fedora-coder` box.
- Picking the right power-user base image for a coding/dev workload (this
- vs. `charly-fedora` vs. `charly-arch` vs. `openclaw-desktop`).
+ vs. `charly-fedora` vs. `charly-arch`).
- Understanding the rootless-first architectural pattern shared by the
four power-user boxes (kernel RCA belongs in
[`/charly-distros:container-nesting`](/recipes/distros/container-nesting/); the composition that proves it works
diff --git a/src/content/docs/recipes/coder/gemini.md b/src/content/docs/recipes/coder/gemini.md
index 35df2ab0..0d9588af 100644
--- a/src/content/docs/recipes/coder/gemini.md
+++ b/src/content/docs/recipes/coder/gemini.md
@@ -28,12 +28,11 @@ my-box:
## Used In Boxes
-- `openclaw-full` (via `openclaw-full` metalayer)
## Related Candies
- [`/charly-coder:nodejs`](/recipes/coder/nodejs/) — required runtime dependency
-- [`/charly-coder:claude-code`](/recipes/coder/claude-code/) — sibling AI CLI in openclaw-full and hermes-full
-- [`/charly-coder:codex`](/recipes/coder/codex/) — sibling AI CLI in openclaw-full and hermes-full
+- [`/charly-coder:claude-code`](/recipes/coder/claude-code/) — sibling AI CLI in hermes-full
+- [`/charly-coder:codex`](/recipes/coder/codex/) — sibling AI CLI in hermes-full
## Related Commands
- [`/charly-build:secrets`](/recipes/build/secrets/) — provision Gemini API credentials for the CLI
diff --git a/src/content/docs/recipes/coder/gh.md b/src/content/docs/recipes/coder/gh.md
index 9a50e524..7f5009de 100644
--- a/src/content/docs/recipes/coder/gh.md
+++ b/src/content/docs/recipes/coder/gh.md
@@ -82,7 +82,6 @@ my-box:
## Used In Boxes
- [`/charly-coder:charly-arch`](/recipes/coder/charly-arch/), [`/charly-distros:charly-fedora`](/recipes/distros/charly-fedora/), [`/charly-coder:fedora-coder`](/recipes/coder/fedora-coder/) — power-user boxes that compose gh explicitly
-- [`/charly-openclaw:openclaw-desktop`](/recipes/openclaw/openclaw-desktop/) — streaming-desktop sibling
- Any box composing `hermes-full`
## Related Candies
diff --git a/src/content/docs/recipes/coder/kimi.md b/src/content/docs/recipes/coder/kimi.md
index 857895b1..7264a1a8 100644
--- a/src/content/docs/recipes/coder/kimi.md
+++ b/src/content/docs/recipes/coder/kimi.md
@@ -28,7 +28,7 @@ my-dev:
## Used In Boxes
- No enabled boxes use this candy yet — composition into the AI-CLI metalayers
- (`hermes-full`, `openclaw-full`) and the coder boxes is the named
+ (`hermes-full`) and the coder boxes is the named
`@github`-pin follow-up once the candy carries a published tag (see
[`/charly-build:reconcile`](/recipes/build/reconcile/))
diff --git a/src/content/docs/recipes/coder/language-runtimes.md b/src/content/docs/recipes/coder/language-runtimes.md
index 38824745..f7593388 100644
--- a/src/content/docs/recipes/coder/language-runtimes.md
+++ b/src/content/docs/recipes/coder/language-runtimes.md
@@ -105,7 +105,7 @@ Python env). It installs `python3-devel` + `python3-ramalama` via RPM —
Consequence for [`/charly-coder:fedora-coder`](/recipes/coder/fedora-coder/) (the biggest consumer): the whole
`python` / `pixi` charly-layer chain stays out of the resolved candy set (because
[`/charly-coder:uv`](/recipes/coder/uv/) and [`/charly-infrastructure:supervisord`](/recipes/infrastructure/supervisord/) likewise carry no python
-dep). See the project rulebook "Key Rules" (`AGENTS.md` / `CLAUDE.md`) → *"Don't declare defensive deps"* for the
+dep). See the project rulebook "Key Rules" (`AGENTS.md`) → *"Don't declare defensive deps"* for the
general rule.
If you genuinely need the pixi-python env (e.g. a candy that
diff --git a/src/content/docs/recipes/coder/nodejs.md b/src/content/docs/recipes/coder/nodejs.md
index ee15f485..db1421e3 100644
--- a/src/content/docs/recipes/coder/nodejs.md
+++ b/src/content/docs/recipes/coder/nodejs.md
@@ -42,6 +42,29 @@ binary on `/usr/local/bin` is on the default system PATH for every user (incl.
root — Immich runs its pnpm build as root). Immich and other pnpm consumers use
this pnpm to drive their build.
+### The pin and a consumer's `allowBuilds` KEY FORM are a COUPLED PAIR
+
+pnpm >= 10 blocks a dependency's build/install scripts unless it is allowlisted,
+and for a **git-specifier** dependency the KEY FORM of that allowlist entry is
+version-coupled: the two forms fail on each other's pin, so no single form works
+at both. MEASURED in the real build image (Fedora 43, node 22.22.2) against
+pod-dsh's `dsh-web` profile — `opencharly/layer-nodejs#5`:
+
+| `allowBuilds` key form | pnpm `10.33.4` | pnpm `10.34.6` |
+|---|---|---|
+| bare name — `"@perrylink/dsh-github": true` | PASS | `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` |
+| fully-qualified URL key — `"@perrylink/dsh-github@https://codeload.github.com///.git": true` | `ERR_PNPM_INVALID_VERSION_UNION` | PASS |
+
+A wrong pair hard-fails the consumer's profile install at IMAGE BUILD time. So
+`PNPM_VERSION`, `PNPM_ALLOWBUILDS_GIT_KEY_FORM` and the two PROBED_* constants move
+together, and the candy's own
+`check: the pinned pnpm version's allowBuilds git-key form matches the recorded measurement`
+makes a bump that forgets them RED instead of a hard-failed consumer build. Only the
+two probed versions are accepted: a bump to any other pin must RE-MEASURE the form and
+record it. (pod-dsh vendored its git-hosted plugins as prebuilt `file:` tarballs in
+pod-dsh#28, so no git dep remains there today — the rule still binds any consumer
+that pins one.)
+
## Usage
```yaml
diff --git a/src/content/docs/recipes/coder/oracle.md b/src/content/docs/recipes/coder/oracle.md
index bd67c8b7..d84ad09d 100644
--- a/src/content/docs/recipes/coder/oracle.md
+++ b/src/content/docs/recipes/coder/oracle.md
@@ -28,11 +28,9 @@ my-box:
## Used In Boxes
-- `openclaw-full` (via `openclaw-full` metalayer)
## Related Candies
- [`/charly-coder:nodejs`](/recipes/coder/nodejs/) -- runtime dependency
-- [`/charly-openclaw:openclaw-full`](/recipes/openclaw/openclaw-full/) -- parent metalayer that bundles oracle
## Related Commands
- [`/charly-automation:openclaw-deploy`](/recipes/automation/openclaw-deploy/) -- gateway/skill configuration
diff --git a/src/content/docs/recipes/core/charly-config.md b/src/content/docs/recipes/core/charly-config.md
index 987e08a2..40d18cb4 100644
--- a/src/content/docs/recipes/core/charly-config.md
+++ b/src/content/docs/recipes/core/charly-config.md
@@ -15,9 +15,9 @@ description: "`charly config` commands, image deployment setup, quadlet generati
This is the **single entry point** for **container** deployment setup. `charly start` requires `charly config` to have been run first in quadlet mode.
-**Relationship to `charly fleet add`** — `charly config` remains the primary way to create/update a quadlet and provision secrets/volumes/sidecars for a container deploy. `charly fleet add ` (container target) wraps both `charly config` and `charly start` and additionally handles `--add-candy` overlay synthesis (an overlay Containerfile is built before the quadlet references the resulting overlay image). `charly fleet add host` bypasses `charly config` entirely — the host target has no quadlet; it writes systemd units directly (when `--with-services` is enabled) and records every action in the ledger at `~/.config/opencharly/installed/`. See [`/charly-core:deploy`](/recipes/core/deploy/) for the command family and [`/charly-local:local-deploy`](/recipes/local/local-deploy/) for host-target semantics.
+**Relationship to `charly deploy add`** — `charly config` remains the primary way to create/update a quadlet and provision secrets/volumes/sidecars for a container deploy. `charly deploy add ` (container target) wraps both `charly config` and `charly start` and additionally handles `--add-candy` overlay synthesis (an overlay Containerfile is built before the quadlet references the resulting overlay image). `charly deploy add host` bypasses `charly config` entirely — the host target has no quadlet; it writes systemd units directly (when `--with-services` is enabled) and records every action in the ledger at `~/.config/opencharly/installed/`. See [`/charly-core:deploy`](/recipes/core/deploy/) for the command family and [`/charly-local:local-deploy`](/recipes/local/local-deploy/) for host-target semantics.
-**`charly config` vs `charly settings` — common verb confusion.** `charly config ` configures an image for deployment (quadlet + secrets + volumes + data seed). `charly settings list` shows runtime config keys (secret_backend, vm.backend, etc.). A trailing `charly config show` parses as `charly config setup show` with `show` as the image positional — and errors with `image "show" is not available locally`. To inspect runtime configuration use `charly settings list`; to inspect a configured image's resolved deploy state use `charly box inspect ` or `charly fleet show `.
+**`charly config` vs `charly settings` — common verb confusion.** `charly config ` configures an image for deployment (quadlet + secrets + volumes + data seed). `charly settings list` shows runtime config keys (secret_backend, vm.backend, etc.). A trailing `charly config show` parses as `charly config setup show` with `show` as the image positional — and errors with `image "show" is not available locally`. To inspect runtime configuration use `charly settings list`; to inspect a configured image's resolved deploy state use `charly box inspect ` or `charly deploy show `.
## Quick Reference
@@ -264,12 +264,12 @@ Warning: port conflicts detected:
Wrote /home/user/.config/containers/systemd/charly--.container
```
-The quadlet is still written with the conflicting port, so `charly start` will fail at bind time unless you either free the port, re-run `charly config` with a remap, or edit `charly.yml`. Detection uses a local `net.Listen` probe per published port at configure time. Handy for multi-instance deployments alongside a production fleet (e.g., running [`/charly-openclaw:openclaw-desktop`](/recipes/openclaw/openclaw-desktop/) as a test instance while [`/charly-selkies:selkies-labwc`](/recipes/selkies/selkies-labwc/) holds the canonical 3000/9222/9224/2222 range).
+The quadlet is still written with the conflicting port, so `charly start` will fail at bind time unless you either free the port, re-run `charly config` with a remap, or edit `charly.yml`. Detection uses a local `net.Listen` probe per published port at configure time. Handy for multi-instance deployments alongside a production fleet (e.g., running [`/charly-distros:charly-fedora`](/recipes/distros/charly-fedora/) as a test instance while [`/charly-selkies:selkies-labwc`](/recipes/selkies/selkies-labwc/) holds the canonical 3000/9222/9224/2222 range).
## Deploy State
All configuration is persisted to `~/.config/charly/charly.yml` in the compact
-node-form — a `version:` stamp, an optional top-level `provides:` directive, and each
+node-form — an optional top-level `provides:` directive, and each
deploy as a name-first `: {: }` entry whose substrate-kind
value carries the scalars, collections (`port:`/`volume:`/…), and `plan:` inline, with
any nested/peer members as sibling child nodes (no `deploy:` map wrapper, no `target:`
@@ -315,12 +315,12 @@ charly config my-app --bind workspace=/new/path
### Full instance removal
-`charly remove -i ` is the complete teardown: it stops the container, deletes the quadlet file, and reloads systemd. `charly config remove` only disables the quadlet service (the quadlet file remains), and `charly fleet reset` removes the charly.yml entry — neither is a full removal on its own:
+`charly remove -i ` is the complete teardown: it stops the container, deletes the quadlet file, and reloads systemd. `charly config remove` only disables the quadlet service (the quadlet file remains), and `charly deploy reset` removes the charly.yml entry — neither is a full removal on its own:
```bash
charly remove -i # Complete teardown: stop container, delete quadlet, reload systemd
charly config remove -i # Disable quadlet service only (quadlet file remains)
-charly fleet reset -i # Remove charly.yml entry only
+charly deploy reset -i # Remove charly.yml entry only
```
Run `charly config --update-all` after removing an instance so the clean state propagates to the remaining deployments.
@@ -489,7 +489,7 @@ When `charly config ` runs, it automatically migrates any existing plaint
1. Scan `dc.Images[deployKey(image, instance)].Env` for names that match `meta.SecretAccepts` / `meta.SecretRequires`
2. For each match: copy the value to the credential store at the layer-declared `(service, key)` path (default `charly/secret/`), remove the entry from `dc.Env`, mark the deploy config dirty
3. On first mutation, back up `charly.yml` → `charly.yml.bak.`
-4. Persist the cleaned deploy config via `SaveFleetConfig`
+4. Persist the cleaned deploy config via `SaveDeployConfig`
5. Log each migrated entry on stderr: `"Migrated plaintext OPENROUTER_API_KEY from charly.yml to credential store (charly/api-key/openrouter)"`
Idempotent — running on a clean host is a no-op. This gives pre-upgrade hosts an automatic one-time cleanup with a rollback point preserved.
@@ -563,13 +563,13 @@ Kong `sep:"none"` on all `-e` flags means commas in values are preserved (no spl
`normalizeNoProxy()` auto-converts semicolons to commas in `NO_PROXY`/`no_proxy` values during env resolution. Legacy semicolon values in charly.yml are auto-healed.
-**NO_PROXY enrichment:** When `HTTP_PROXY` or `HTTPS_PROXY` is present, `charly config` automatically appends all deployed container hostnames to `NO_PROXY`. This is necessary because Chrome does not support CIDR ranges in NO_PROXY (unlike curl) — without explicit hostnames, Chrome routes internal traffic like `http://charly-immich-ml:2283` through the external proxy, causing Bad Gateway errors. Applied in both the main config path and `--update-all`. Source: `spec/hostenv/envfile.go` (`EnrichNoProxy`), `sdk/deploykit/fleet_derive.go` (`DeployedContainerNames`).
+**NO_PROXY enrichment:** When `HTTP_PROXY` or `HTTPS_PROXY` is present, `charly config` automatically appends all deployed container hostnames to `NO_PROXY`. This is necessary because Chrome does not support CIDR ranges in NO_PROXY (unlike curl) — without explicit hostnames, Chrome routes internal traffic like `http://charly-immich-ml:2283` through the external proxy, causing Bad Gateway errors. Applied in both the main config path and `--update-all`. Source: `spec/hostenv/envfile.go` (`EnrichNoProxy`), `sdk/deploykit/deploy_derive.go` (`DeployedContainerNames`).
**Tunnel persistence:** `charly config setup` automatically persists tunnel config from charly.yml back to charly.yml via `saveDeployState`. Tunnel is a deploy-time concern — see [`/charly-core:deploy`](/recipes/core/deploy/) for tunnel configuration.
**Tunnel is charly.yml-only:** `labels.go:238` deliberately skips parsing the `ai.opencharly.tunnel` OCI image label. Tunnel config is ONLY sourced from `charly.yml`. New instances created with `charly config setup -i ` do NOT inherit tunnel config from the base image's charly.yml entry — you must manually add `tunnel: {provider: tailscale, private: all}` to the instance's charly.yml entry, then re-run `charly config setup` to regenerate the quadlet with `ExecStartPost=tailscale serve` commands.
-Source: `spec/hostenv/envfile.go` (`normalizeNoProxy`), `sdk/deploykit/deploy_state.go` (`MergeEnvVars`, `SaveDeployState`), `sep:"none"` in `candy/plugin-fleet/fleet_cmd.go` + `candy/plugin-pod/pod_cmd.go` (the former `charly/config_image.go`/`shell.go`/`start.go` are DELETED, K-wave 2).
+Source: `spec/hostenv/envfile.go` (`normalizeNoProxy`), `sdk/deploykit/deploy_state.go` (`MergeEnvVars`, `SaveDeployState`), `sep:"none"` in `candy/plugin-fleet/deploy_cmd.go` + `candy/plugin-pod/pod_cmd.go` (the former `charly/config_image.go`/`shell.go`/`start.go` are DELETED, K-wave 2).
## Cross-References
diff --git a/src/content/docs/recipes/core/charly-status.md b/src/content/docs/recipes/core/charly-status.md
index ccc0af3a..282fb12a 100644
--- a/src/content/docs/recipes/core/charly-status.md
+++ b/src/content/docs/recipes/core/charly-status.md
@@ -69,10 +69,10 @@ former `status-substrate` HostBuild seam is DELETED, K-wave 2).
- `candy/plugin-status/nested_tree.go` — the declared-nested-tree pre-resolution
(K5, relocated from charly/status_nested.go's buildStatusRootsTree): resolves
the merged project + per-machine deploy tree directly (`HostBuild("resolved-
- project")` + `deploykit.LoadFleetConfig`) into the wire-safe
+ project")` + `deploykit.LoadDeployConfig`) into the wire-safe
`[]spec.StatusNestedNode` shape the overlay folds, including the `--nested`
live-probe leg (`ResolveDeployChain` + `NestedExecutor`, the SAME primitive
- `charly fleet add` / `charly check live parent.child` use).
+ `charly deploy add` / `charly check live parent.child` use).
- `candy/plugin-status/command.go` (`hostStatusSubstrate`) — the generic
`status-substrate` F10 forward is DELETED with `charly/status_substrate_host.go`
(K-wave 2): `command:status` now `InvokeProvider(verb:status-fanout)` DIRECTLY over
@@ -200,7 +200,7 @@ match keeps the synthesized declared row (`Source="nested"`).
subprocesses.
- **`charly status --nested`**: each child's LIVE venue is probed through the real
multi-hop chain (`ResolveDeployChain` → `NestedExecutor`, the SAME primitive
- `charly fleet add` and `charly check live parent.child` use — no bespoke nested dial)
+ `charly deploy add` and `charly check live parent.child` use — no bespoke nested dial)
under a STRICT 4-second per-child context deadline. A timed-out / failing
child renders `unreachable`; the table is NEVER blocked. The deadline is a
context cancellation, never a sleep/retry loop. `--nested` also turns on live
@@ -318,7 +318,7 @@ Authoritative direct queries (when you need the raw mount data):
```bash
charly status --json # volumes[] carries the live mounts
-charly fleet show
+charly deploy show
```
## Usage
diff --git a/src/content/docs/recipes/core/charly-update.md b/src/content/docs/recipes/core/charly-update.md
index e54412bb..d0d55e1a 100644
--- a/src/content/docs/recipes/core/charly-update.md
+++ b/src/content/docs/recipes/core/charly-update.md
@@ -11,11 +11,11 @@ description: "Update image and restart service with data sync — `charly update
## Overview
-Redeploy the current artifact and restart the service — for EVERY deploy kind through ONE codepath. `charly update ` resolves the deploy via `ResolveTarget` and calls `LifecycleTarget.Rebuild` (`charly/unified_targets_*.go`); there is no per-kind update code. The unified contract is **redeploy the current artifact + restart by default; `--build` rebuilds the artifact first** — realized per substrate: pod → `deploy add → config → start` (`--build` rebuilds the image); vm → destroy→create the domain (reuse the qcow2 disk unless `--build`) **then re-apply the deploy node's layers idempotently** via the shared `deploy add` path (so a config change — a newly-added layer or nested pod — takes effect on the rebuilt guest, exactly like local/pod); local → re-apply layers idempotently. All three live substrates end in the SAME `charly fleet add ` layer-apply step. kubernetes has no live runtime to rebuild (apply it via `kubectl apply -k`), so `charly update ` errors uniformly. Data sync uses MERGE mode by default — adds new files without overwriting existing user modifications.
+Redeploy the current artifact and restart the service — for EVERY deploy kind through ONE codepath. `charly update ` resolves the deploy via `ResolveTarget` and calls `LifecycleTarget.Rebuild` (`charly/unified_targets_*.go`); there is no per-kind update code. The unified contract is **redeploy the current artifact + restart by default; `--build` rebuilds the artifact first** — realized per substrate: pod → `deploy add → config → start` (`--build` rebuilds the image); vm → destroy→create the domain (reuse the qcow2 disk unless `--build`) **then re-apply the deploy node's layers idempotently** via the shared `deploy add` path (so a config change — a newly-added layer or nested pod — takes effect on the rebuilt guest, exactly like local/pod); local → re-apply layers idempotently. All three live substrates end in the SAME `charly deploy add ` layer-apply step. kubernetes has no live runtime to rebuild (apply it via `kubectl apply -k`), so `charly update ` errors uniformly. Data sync uses MERGE mode by default — adds new files without overwriting existing user modifications.
**`charly update` does NOT auto-pull.** It redeploys the image already in local storage. To advance a deploy to a newer published image, `charly box pull ` first, then `charly update`; or `charly update --build` to rebuild locally. (This consistency with vm's reuse-disk default replaced the former pod-only auto-pull, so `charly update` behaves identically across kinds.) See [`/charly-core:deploy`](/recipes/core/deploy/) for the unified command family and [`/charly-local:local-deploy`](/recipes/local/local-deploy/) for host-target specifics.
-**`charly update` obeys an EXPLICIT invocation on ANY target.** It does NOT refuse a non-`disposable: true` deploy — for a target that is neither disposable nor ephemeral it prints a one-line transparency note (`noteUpdateDisposability`, naming the deploy key + lifecycle, so the operator can catch a mistyped name) and proceeds with the rebuild. The `disposable:` flag stays load-bearing as the authorization for UNATTENDED autonomous destroy + rebuild (the project rulebook R10 (`AGENTS.md` / `CLAUDE.md`)) and for the check-runner's unattended fresh-rebuild (`validateCheckBeds`); it does NOT gate this explicitly-invoked verb. See [`/charly-internals:disposable`](/recipes/internals/disposable/).
+**`charly update` obeys an EXPLICIT invocation on ANY target.** It does NOT refuse a non-`disposable: true` deploy — for a target that is neither disposable nor ephemeral it prints a one-line transparency note (`noteUpdateDisposability`, naming the deploy key + lifecycle, so the operator can catch a mistyped name) and proceeds with the rebuild. The `disposable:` flag stays load-bearing as the authorization for UNATTENDED autonomous destroy + rebuild (the project rulebook R10 (`AGENTS.md`)) and for the check-runner's unattended fresh-rebuild (`validateCheckBeds`); it does NOT gate this explicitly-invoked verb. See [`/charly-internals:disposable`](/recipes/internals/disposable/).
## Quick Reference
diff --git a/src/content/docs/recipes/core/clean.md b/src/content/docs/recipes/core/clean.md
index c9e08c9e..498bb491 100644
--- a/src/content/docs/recipes/core/clean.md
+++ b/src/content/docs/recipes/core/clean.md
@@ -1,6 +1,6 @@
---
title: "clean"
-description: "Prune reusable build artifacts to defaults: retention (images, check runs) and sweep one-time makepkg leftovers — `charly clean`, keep_images/keep_check_runs, image-tag pruning, .check run cleanup."
+description: "Prune reusable build artifacts to defaults: retention (images, check runs) and sweep one-time build-candy leftovers — `charly clean`, keep_images/keep_check_runs, image-tag pruning, .check run cleanup."
---
@@ -39,7 +39,7 @@ opts in; third-party configs get no surprise pruning until they set a value.
## Commands
```bash
-charly clean # apply retention now: prune images + check runs + makepkg leftovers
+charly clean # apply retention now: prune images + check runs + build-candy leftovers
charly clean --dry-run # print everything that WOULD be removed; touch nothing
charly clean --images # only image-tag retention
charly clean --check # only check-run retention
@@ -50,12 +50,28 @@ charly clean --deep --dry-run # the safe default probe: report the would-remov
charly clean --keep N # override the retention count for this run (0 = use defaults:)
charly clean --invalidate '' # remove charly-labeled image tags matching the glob
# (full ref or last segment; in-use skipped; runs ONLY this)
+charly clean --cache # reclaim unreferenced blobs from every named charly cache (the
+ # content-addressed ArtifactStore's own GC); reports each store's live
+ # entries + the blobs/bytes it would reclaim; runs ONLY this category
+charly clean --scopes # reap HUNG transient build scopes — the systemd user scopes
+ # buildah/podman put a build container into (runc-buildah-*.scope).
+ # A scope is stopped only when it has hung; a live one is left alone
```
-With neither `--images` nor `--check` nor `--deep`, the three DEFAULT categories run (images +
-check + makepkg) — unchanged, `--deep` NEVER fires implicitly. `--keep N` overrides both the
-image and check-run retention counts for the invocation (it does not affect `--deep`, which has
-no keep-N — it purges every untagged image).
+With **none** of `--images` / `--check` / `--deep` / `--cache` / `--scopes`, the **four** DEFAULT categories
+run (image-tag + check-run + build-candy + scopes) — unchanged, and `--deep` and `--cache` NEVER fire
+implicitly. `--scopes` IS in that list because it is itself a category flag: the test the code applies is
+`anyCategory := images || check || deep || cacheGC || scopes`, so naming ANY one of the five suppresses the
+default set and runs only what was named (plus, for `--deep`, the scope reap below).
+**`--scopes` is a default and `--deep` implies it:** `cleanCategories` computes
+`doScopes = scopes || deep || !anyCategory`, so a bare `charly clean` reaps hung build scopes
+and `--deep` does too, while `--deep` on its own runs only the image half plus that scope reap — and
+`--cache` on its own runs only the cache sweep, with `doScopes` false.
+`--keep N` overrides both the image and check-run retention counts for the invocation (it does
+not affect `--deep`, which has no keep-N — it purges every untagged image).
+**Category names, from the engine's own header** (`retention.go`): *image-tag / build-candy / check-run
+pruning*, plus the `--deep` store-wide purge and the `--cache` blob sweep. The middle one prunes
+`.build/_candy/./` per candy (`pruneBuildCandyDirs`).
**`--deep` — the store-wide untagged/dangling-image purge.** The default image-tag sweep above
only ever touches images carrying the `ai.opencharly.box` label. A multi-stage build's
@@ -195,7 +211,7 @@ importing only `sdk/kit` + `spec/spec`). `--deep` shares its engine with the def
charly-labeled dangling sweep via `pruneDanglingImages`/`selectDanglingImages`
(`candy/plugin-clean/retention.go`) parameterized by an `onlyCharly` bool: `pruneDanglingCharlyImages`
(onlyCharly=true, the default sweep) and `pruneDeepDanglingImages` (onlyCharly=false, `--deep`)
-are both thin wrappers over the ONE shared selection + removal engine (R3 — no duplicated
+are both thin wrappers over the ONE shared selection + removal engine (R2 — no duplicated
listing/removal logic between the two categories). The engine is reached two ways: `charly
clean`'s own CLI calls `runRetention` in-package (no wire hop); the three OTHER callers reach
`verb:retention` — the build command's post-build prune (`candy/plugin-box/box.go`'s
diff --git a/src/content/docs/recipes/core/deploy.md b/src/content/docs/recipes/core/deploy.md
index 8c19e8c6..e73004b0 100644
--- a/src/content/docs/recipes/core/deploy.md
+++ b/src/content/docs/recipes/core/deploy.md
@@ -1,6 +1,6 @@
---
title: "deploy"
-description: "`charly fleet add`/`charly fleet del`, quadlet generation, volume backing, tunnels (Tailscale/Cloudflare), `add_candy:` overlay, or per-machine deploy overlays."
+description: "`charly deploy add`/`charly deploy del`, quadlet generation, volume backing, tunnels (Tailscale/Cloudflare), `add_candy:` overlay, or per-machine deploy overlays."
---
@@ -15,26 +15,26 @@ description: "`charly fleet add`/`charly fleet del`, quadlet generation, volume
- **The substrate node at the edge** — `vm: {from: }` for a VM target, `pod: {image: }` for a pod target, `kubernetes: {from: }` for a cluster target, `local: {from: }` for a local-deploy. Deploy-into nesting is **tree position** (a resource node placed under another resource), not a `nested:` field; sibling members (a resource node placed under the deploy) replace the old `peer:` field.
- **`host:` is a FIELD on `local:`, never a deploy KIND (MUST).** A host/remote deploy MUST be authored as `local: {from: , host: }` — the `host:` scalar on a `local:` (or `pod:`) deploy: `host: local` / absent = this machine, `host: ` = SSH. There is NO standalone `host:` venue KIND; authoring a `host:` node is a hard load error. See [`/charly-local:local-deploy`](/recipes/local/local-deploy/).
- **`group:` is EXCLUSIVELY a targetless deploy group.** A `group:` node carries only resource members (pod/vm/etc.) with no own workload — the targetless deploy group.
-- **Disposability is a deploy property** — `FleetNode.Disposable` (the `disposable:` scalar under the substrate node) is the sole source of truth.
+- **Disposability is a deploy property** — `DeployNode.Disposable` (the `disposable:` scalar under the substrate node) is the sole source of truth.
- **Resource arbitration is a deploy property** — the `preemptible:` field (holder; occupies exclusive host-resource token(s), may be gracefully stopped + restored), the `requires_exclusive:` field (claimant; needs SOLE use), and the `requires_shared:` field (claimant; REFCOUNTED shared use — many concurrent claimants; a GPU-token claim flips the whole IOMMU group vfio→nvidia+CDI at deploy, back at teardown when the LAST claimant releases) — all inline in the substrate node — drive the arbiter (`charly preempt`). A fourth axis ORTHOGONAL to disposable/ephemeral/lifecycle. See "Preemptible resource arbitration" below + [`/charly-internals:disposable`](/recipes/internals/disposable/).
- **Disposable R10 test beds are just `disposable: true` deploys** (run via `charly check run `) — there is NO separate bed kind. The main repo's beds (`check-pod`, `check-k3s-vm`, `check-sway-browser-vnc-pod`, …) AND every `box/` submodule's beds (the arch / cachyos / debian / ubuntu / fedora bootstrap-VM + pacstrap/debootstrap beds, in each submodule's `charly.yml`) are disposable deploys. The cachyos submodule's `charly-cachyos` operator workstation profile is a non-disposable deploy (a profile, not a test bed). Operator deployments otherwise live in the per-host `~/.config/charly/charly.yml`. See [`/charly-check:check`](/recipes/check/check/) "disposable check deploys".
## Overview
-`charly fleet` is the parent verb for applying and tearing down deployments, plus managing `charly.yml` overrides. The command family has two distinct surfaces:
+`charly deploy` is the parent verb for applying and tearing down deployments, plus managing `charly.yml` overrides. The command family has two distinct surfaces:
-1. **Execution verbs** — `charly fleet add ` / `charly fleet del `. Apply or reverse a deployment. The target is dispatched by which substrate node the deploy carries:
+1. **Execution verbs** — `charly deploy add ` / `charly deploy del `. Apply or reverse a deployment. The target is dispatched by which substrate node the deploy carries:
- `local: {from: }` → the external `deploy:local` plugin (`candy/plugin-deploy-local`) applies to the local filesystem over the executor reverse channel (or, placed under another resource node via tree position, via NestedExecutor into the enclosing deployment). See [`/charly-local:local-deploy`](/recipes/local/local-deploy/).
- `vm: {from: }` → the external `deploy:vm` plugin (`candy/plugin-deploy-vm`) applies INSIDE a running VM over SSH via the reverse channel; the plugin auto-boots the venue in its `OpPrepareVenue` (via `HostBuild("cli")`), using the host prepare hook's resolved data. See "VM target" section below and [`/charly-internals:vm-deploy-target`](/recipes/internals/vm-deploy-target/).
- `pod: {image: }` → the candy `plugin-deploy-pod` renders the overlay Containerfile (in its own code via `deploykit.OCITarget` + `deploykit.NewRenderGeneratorFromProject`) + the quadlet config-write (fully plugin-side in `candy/plugin-deploy-pod` + `sdk/deploykit/quadlet_paths.go` — the former core quadlet path is DELETED, K-wave 2).
- `kubernetes: {from: }` → Kustomize base/overlays tree. See [`/charly-kubernetes:kubernetes`](/recipes/kubernetes/kubernetes/).
-2. **Config-file management** — `charly fleet show/export/import/reset/path/status`. Read and mutate `~/.config/charly/charly.yml` itself.
+2. **Config-file management** — `charly deploy show/export/import/reset/path/status`. Read and mutate `~/.config/charly/charly.yml` itself.
## Targets, one schema
-The same `FleetNode` shape feeds every target (`pod`, `local`, `vm`, `kubernetes`, `android`) — authors describe *what the workload needs* (ports, volumes, env, security, tests); the generator per target decides *how Kubernetes / quadlet / local apply / VM over SSH / Android apk-install* realizes it.
+The same `DeployNode` shape feeds every target (`pod`, `local`, `vm`, `kubernetes`, `android`) — authors describe *what the workload needs* (ports, volumes, env, security, tests); the generator per target decides *how Kubernetes / quadlet / local apply / VM over SSH / Android apk-install* realizes it.
-A deploy's install timeline can include a `run:` step carrying a plugin verb (the `: ` sugar — see [`/charly-image:layer`](/recipes/image/layer/) "Plugin verb steps"). When the verb is served by an EXTERNAL (out-of-process) plugin, that step EXECUTES at deploy on a `local:`/`vm:` target — the plugin runs its effect on the live venue over the executor reverse channel (`Invoke(OpExecute)`) and returns teardown `ReverseOp`s the target records to the ledger and replays at `charly fleet del`. A builtin `ProvisionActor` verb keeps its in-proc shell path; the two are placement-agnostic (same authored step, the runtime picks the path). Detail → [`/charly-internals:install-plan`](/recipes/internals/install-plan/) (the `ExternalPluginStep` IR kind) + [`/charly-internals:plugin`](/recipes/internals/plugin/).
+A deploy's install timeline can include a `run:` step carrying a plugin verb (the `: ` sugar — see [`/charly-image:layer`](/recipes/image/layer/) "Plugin verb steps"). When the verb is served by an EXTERNAL (out-of-process) plugin, that step EXECUTES at deploy on a `local:`/`vm:` target — the plugin runs its effect on the live venue over the executor reverse channel (`Invoke(OpExecute)`) and returns teardown `ReverseOp`s the target records to the ledger and replays at `charly deploy del`. A builtin `ProvisionActor` verb keeps its in-proc shell path; the two are placement-agnostic (same authored step, the runtime picks the path). Detail → [`/charly-internals:install-plan`](/recipes/internals/install-plan/) (the `ExternalPluginStep` IR kind) + [`/charly-internals:plugin`](/recipes/internals/plugin/).
**An `android:` deploy** installs a deploy's `add_candy:` candies' `apk:` packages
onto an `android` device entity (an in-pod emulator or a remote/physical adb
@@ -45,36 +45,36 @@ a cluster. The substrate node is `android: {from: }`; apps
ride in on the deploy's inline `add_candy:` list (no apk-list field). A `pod → android` tree
(the device placed under its emulator-pod node) mirrors `vm → kubernetes`; a pod's android
children deploy AFTER `charly start` (use `--node-only` on the pod's deploy-add, then
-dotted-path `charly fleet add pod.device`). See [`/charly-check:android`](/recipes/check/android/). Kubernetes-specific choices (storage class, ingress class, cert issuer, secret backend) live in a **cluster profile** file (`~/.config/charly/clusters/.yaml` or in-repo `clusters/.yaml`), *not* in the deployment. This means one deployment spec targets dev/staging/prod clusters with zero schema changes — only the cluster profile differs.
+dotted-path `charly deploy add pod.device`). See [`/charly-check:android`](/recipes/check/android/). Kubernetes-specific choices (storage class, ingress class, cert issuer, secret backend) live in a **cluster profile** file (`~/.config/charly/clusters/.yaml` or in-repo `clusters/