Skip to content

DSL

Soul Stack is described by three kinds of YAML artifacts. They are all typed; expressions inside them are CEL, and file rendering is Go text/template.

ArtifactWhat it describesWhere it lives
DestinyThe desired state of a single host — a set of idempotent steps.git (the destiny repository)
ScenarioDestiny plus orchestration: which hosts, in what order, under what conditions.git (the service repository)
EssenceParameters and secrets bound to a host or group.git (defaults) + database (overrides in spec)
  • Destiny — the atomic building block: “how to bring a single host to the desired state.” Isolated — it sees only its own declared input.
  • Scenario — an operation over a whole cluster: “how to run create / add_user / restart across a set of hosts.” A scenario receives the full set of Destiny task blocks plus the orchestration delta (which hosts, in what order) and can call Destiny through apply:.
  • Essence — the values fed in at render time: application settings, passwords, keys. A scenario sees Essence directly; Destiny receives values only through an explicit hand-off on its input.

The boundary between Destiny and Scenario is a recommendation, not a hard wall: anything reusable, critical, or worth isolating goes into a separate Destiny; one-off logic for a specific operation is fine inline in the scenario.

Soul Stack has two expression engines, and the boundary between them runs strictly per file:

ContextEngineMarker
Expressions in YAML: conditions (when:, where:, …)CELthe whole string = an expression, no wrapper
Value interpolation inside YAML stringsCEL${ … }
Template files with the .tmpl extensionGo text/template{{ … }}

Only one engine runs per file: CEL never executes inside a .tmpl, and text/template never executes inside a .yml. The two don’t overlap — they hand data off in sequence: CEL computes values in the YAML, and then the explicitly lifted values flow into the file render.

CEL handles every expression in YAML artifacts. Two positions:

  • Conditional keys (when:, where:, changed_when:, failed_when:, until:) — the whole string is treated as a CEL expression, with no wrapper:

    when: input.do_restart
    where: register.role.stdout == "master"
  • Interpolation in string values (params:, vars:, input hand-off) — the expression is wrapped in ${ … }:

    params:
    command: "redis-cli replicaof ${ register.master.stdout } 6379"
    replicas: "${ input.replicas * 2 }"

CEL is a side-effect-free expression engine: it has no access to the filesystem, to commands, or to arbitrary network. Expressions can reach the host’s facts (soulprint.self.<path>), parameters (input.*, essence.*), results of previous steps (register.*), and a set of built-in functions (checks, size, regexes, time handling).

Template files with the .tmpl extension are rendered by the Go text/template engine. This is the only place it runs, and it is used only by the dedicated step that renders a file on the host.

Rendering is safe by default: strict mode (referencing a missing field is an error, not an empty string), a closed allow-list of functions (no running commands, reading the environment, or generating randomness), and an isolated context (the template sees only the explicitly lifted values, not the whole scenario context).

Secrets are resolved through Vault on the server side at render time. The expression engine treats them as ordinary values — otherwise you could not legitimately pass a secret into a parameter. Masking is applied on output: secrets never appear in cleartext in logs, traces, API responses, or run reports.

Artifacts can be checked before apply with the offline linter soul-lint: it parses the YAML, verifies module and state names, and checks parameter schemas and expressions — on a workstation or in CI, without executing anything on hosts.