Skip to content

Idempotent steps and check mode

An idempotent step describes desired state rather than a fixed sequence of commands. You give the step a check function that inspects the world and a run function that converges it. KiCI then executes the workflow in one of two modes:

  • Apply mode (the default): for each step, check() runs first; on drift the step applies the change; when already in sync the step is skipped.
  • Check mode (--check): for each step, check() runs and KiCI reports what would change — without changing anything. This is the same model as a dry-run plan: you see the drift before any side effect happens.

This turns a workflow into convergent configuration management: re-running an apply is safe (in-sync steps do nothing), and a check-mode run is a read-only preview you can gate a build on.

Add a check facet to the existing step() factory. When check is present, run becomes the apply function and receives the drift value check returned:

import { step, z } from '@kici-dev/sdk';
const configureNginx = step('configure-nginx', {
// optional schema for the drift value — gives the dashboard a typed shape
drift: z.object({ want: z.string() }),
// read-only inspection; return null when already in the desired state
check: async (ctx) => {
const current = await ctx.$`nginx -T`;
return current.stdout.includes(DESIRED) ? null : { want: DESIRED };
},
// human-readable preview line — REQUIRED when check is set. It is the drift's
// serializable face: it streams to the logs and persists for the dashboard.
summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
// apply — runs only when check returned drift (apply mode); receives that drift
run: async (ctx, drift) => {
await writeConfig(drift.want);
return { reloaded: true };
},
// optional — runs when check returned null, to produce the step's outputs
whenInSync: async () => ({ reloaded: false }),
});
FieldRequiredPurpose
checkto opt inRead-only inspection. Return a drift value, or null when in sync.
summarizewhen check setHuman-readable, serializable preview of the drift. Streams + persists.
runalwaysApply function. With check, it receives the drift as its second arg.
whenInSyncoptionalProduces the step’s outputs when check returned null.
driftoptionalSchema that validates / shapes the drift value.

summarize is required whenever check is declared. run and whenInSync both produce the same output type — one output shape per step, whichever path runs. Every other step facet (cache, rules, continueOnError, timeout, retry, approval, onCancel, cleanup, outputs) composes unchanged.

A plain step() without check keeps its exact current behavior — the check facet is fully optional.

A run carries one of three modes:

ModeCLI flagsBehavior
apply(default, no flags)Converge: drift ⇒ apply ⇒ applied; null ⇒ in sync (skipped).
check--checkPreview only: drift ⇒ would change; null ⇒ in sync. Never applies. Always exits 0.
check-fail-on-drift--check --fail-on-driftSame as check, but the run fails if any step reports drift.

Per-step outcomes:

  • applied — drift was found and the step applied the change (apply mode).
  • in synccheck returned null; nothing to do.
  • would change — drift was found in check mode; the change was previewed, not applied.
  • no check — a plain step (no check) reached under check mode. A side-effecting step can’t be safely previewed, so it is skipped.

In check mode KiCI never invokes a checked step’s run (apply) — the preview is guaranteed side-effect-free.

--check and --fail-on-drift control drift reporting on kici run remote:

Terminal window
# Apply (default): converge the workflow.
kici run push --local
kici run remote my-fixture
# Check: report drift, change nothing. Always exits 0.
kici run remote my-fixture --check
# Check + fail on drift: fail the run when any step reports drift. Use this as a
# CI gate ("fail the build if prod has drifted").
kici run remote my-fixture --check --fail-on-drift

--fail-on-drift only modifies check mode — passing it without --check is an error.

A check-mode run is labeled in the dashboard with a CHECK MODE — preview badge on the run header. Each step shows its outcome chip — applied / in sync / would change / no check — and, when drift was detected, the summarize line describing what would change. The rendering is read-only.

  • Idempotent SDK helpers — the idempotent() / idempotentStep() convenience wrappers (always apply on drift), plus checkStep(), the clean-shape sibling that respects the run-level check mode.
  • Core SDK reference — the step(), job(), and workflow() factories the check facet extends.
  • Lock file and drift — how the lock file carries step capability flags.