blue specification · 2026-07-10

A uv/Python library for building idempotent devops CLIs: desired state in YAML, workflows as step graphs threaded by one plain dict, Selmer-style template-scaffolded configuration, OpenTofu as the infrastructure runner, and Ansible for SSH provisioning.

Contents
  1. Philosophy
  2. Naming, desired state & the opts contract
  3. Steps and errors
  4. Workflows: wire_fn, next_fn, and composition
  5. Parallelism, fan-out & joins
  6. Advice
  7. blue.yml and validation gates
  8. The scaffolding DSL and renderer
  9. OpenTofu integration
  10. Ansible integration
  11. Dry-run, progress, and runtime
  12. The CLI and ./blue launcher
  13. Examples
  14. Testing strategy
  15. Project layout & API index

1. Philosophy

2. Naming, desired state & the opts contract

Everything called green in the original model and red in the TypeScript implementation is called blue here. Clojure keywords become namespaced string keys, and keyword values become plain strings.

greenredblue
:green/exit"red/exit""blue/exit"
:green.scaffold/written"red.scaffold/written""blue.scaffold/written"
green.ednred.ymlblue.yml
./green./red, a Bun script./blue, a uv script
green.workflowred/workflowblue.workflow
:zk/servers"zk/servers""zk/servers"
:create"create""create"

The base type is open: Opts = dict. blue reserves blue/* and blue.*/* keys; project and library state should live under namespace-like keys such as "zk/servers", "once/workdir", or "tofu/outputs".

KeyMeaning
"blue/event"First positional CLI argument: "create", "delete", "rotate", etc.
"blue/dry-run"Stamped by --dry-run; dry-run advice uses it to skip named steps.
"blue/exit"Integer outcome. Missing means success and is normalized to 0 after a step returns.
"blue/err"Error message when "blue/exit" is positive.
"blue/trace"Stack trace string when a raised exception is converted to a blue failure.
"blue/step"Current step name, stamped before each step runs. Advice such as progress reads it.
"blue/branches"List of per-branch result dicts at a join or collapsed failed fork. Sort by your own key if order matters.
"blue.workflow/inherited"Private advice-inheritance payload. It is stripped from public step and workflow results.
Step input is frozen. The engine deep-freezes opts at each step boundary using FrozenDict and FrozenList. Steps should return new dicts with {**opts, ...} instead of mutating input. If a step raises, the failure opts are built from the frozen input so partial mutation cannot leak.

3. Steps and errors

A step is a plain sync or async function (opts) -> opts, named by a namespaced string such as "zk/node". The name anchors wiring, logging, CLI slices, joins, and advice.

from blue.workflow import Opts, StepError


def check(opts: Opts) -> Opts:
    if not opts.get("app/name"):
        raise StepError("app/name is required", exit=2)
    return {**opts, "blue/exit": 0}

The helper failed(opts) is true when (opts.get("blue/exit") or 0) > 0. Without a next_fn, a failed step produces no successors and that branch halts. With a next_fn, cleanup, retries, and alternate paths are ordinary user routing.

4. Workflows: wire_fn, next_fn, and composition

from blue.workflow import Opts, run, step, workflow

wf = workflow(
    start="zk/start",      # required
    end=None,              # optional inclusive slice boundary
    wire_fn=wire_fn,       # required static graph
    next_fn=next_fn,       # optional dynamic router
)

result = await run(wf, {"blue/event": "create"})

wire_fn — the static graph

wire_fn(step, run_opts) returns a tuple/list containing the step function followed by its default successors for this run. run_opts is the initial opts passed to run, so the graph may depend on stable run-level inputs such as "blue/event".

def wire_fn(step: str, run_opts: Opts):
    match step:
        case "zk/start":
            return (start_step, "zk/node")
        case "zk/node":
            return (node_step, "zk/zoo-cfg")
        case "zk/zoo-cfg":
            return (zoo_cfg_step,)
        case "ci/fork":
            return (fork_step, "ci/a", "ci/b")

Multiple successors mean a static fork. The same graph is used for join scheduling, so keep the happy path visible even when next_fn adds dynamic behavior. Do not base wire_fn on values produced after the run starts; route those cases in next_fn.

next_fn — the dynamic router

When present, next_fn decides successors after each non-end step:

next_fn(step, default_next, opts)  # -> iterable of (next_step, opts) pairs
def next_fn(step: str, default_next: list | None, opts: Opts):
    if (opts.get("blue/exit") or 0) > 0:
        return []
    if step == "zk/start":
        return [("zk/node", {**opts, "zk/node": server})
                for server in opts["zk/servers"]]
    return [(s, opts) for s in (default_next or [])]

Composition — a workflow is a step

step(sub_workflow)
step(sub_workflow,
     in_=lambda opts: sub_opts,
     out=lambda opts, sub_result: parent_result)

Slices

The optional end step is inclusive: it runs, then the workflow stops. The CLI's --start and --end override workflow boundaries for safe idempotent slice runs.

5. Parallelism, fan-out & joins

Join detection. Convergence is by flat step name. The scheduler waits while another live branch can statically reach the same step through this run's wire_fn graph; dynamic detours not visible in that graph may require explicit routing care.

Scheduler algorithm in plain English

  1. Start with one live task: run start with the initial opts.
  2. Loop while work remains, tracking live branches and finished terminals.
  3. Group live entries by step.
  4. Run only ready steps: if a different live branch can still reach a step, wait so it can join.
  5. Run ready units concurrently with asyncio tasks.
  6. Expand each result into zero, one, or many next entries.
  7. When different origins arrive at one step, run a join unit once.
  8. Collapse failed forks before scheduling more work.
  9. Finalize to one opts dict: first failure wins, otherwise the last success.

Independent pipelines inside a fan-out

When next_fn fans out N branches and each branch traverses multiple steps in sequence, converging on the same second step can be interpreted as a join. Wrap the per-item pipeline in a sub-workflow with step(sub_workflow) when the count is dynamic — N items, N nodes, N tenants.

pipeline_wf = workflow(
    start="item/validate",
    wire_fn=lambda step, _run_opts: {
        "item/validate": (validate, "item/transform"),
        "item/transform": (transform, "item/load"),
        "item/load": (load,),
    }.get(step),
)


def parent_wire_fn(step_name: str, _run_opts: Opts):
    return {
        "batch/start": (start, "batch/process"),
        "batch/process": (step(pipeline_wf),),
    }.get(step_name)
When you don't need step. If the number of branches is fixed and known at design time, distinct step names work: "deploy/staging" and "deploy/production" never converge, so they run independently without a sub-workflow.

6. Advice

Advice wraps step functions without changing graph wiring. Registries are immutable workflow data; adding/removing advice returns a new workflow.

from blue.workflow import advice_add, advice_add_all, advice_remove, advice_remove_all

advised = advice_add(wf, "zk/node", "before", "app/backend", write_backend)
with_progress = advice_add_all(advised, "around", "app/progress", progress)
without_backend = advice_remove(with_progress, "zk/node", "app/backend")
quiet = advice_remove_all(without_backend, "app/progress")
HowEffectUse cases
"around"advice(base, opts)Dry-run, retry, timing, locks; may call the base zero, one, or many times.
"override"advice(opts), base never runsReplace/stub a step.
"before"Advice side effect, then base; advice return ignoredSetup: backend files, inventories, directories, locks.
"after"Base, then advice side effect; base return flows outAudit, metrics, notifications, cleanup.
"before-while"Continue inward only while advice returns truthyValidation and policy gates.
"before-until"Use advice result when it is truthy; otherwise baseFast path / already converged no-op.
"after-while"Run advice only when the inward result is blue-trueSuccess-only verification or registration.
"after-until"Use inward result if blue-true; otherwise advice fallbackRecovery or alternate result.
"filter-args"Transform opts before baseNormalize/scope inputs.
"filter-return"Transform base resultNormalize/enrich outputs.

Advice functions may be sync or async; composition awaits each layer. For while/until combinators, blue uses Clojure-style truthiness: only None and False are falsy, so 0 and "" are truthy. For after-while and after-until, a step-result dict is true when "blue/exit" is missing or zero and false when it is positive.

Stacking is Emacs-like. At equal depth, newest advice is outermost. depth (-100..100, default 0) overrides add order: lower depths run farther outside, higher depths farther inside. Re-adding the same id replaces the old entry and moves it according to the new sequence/depth.

Advice inheritance through step. The enclosing run stamps its effective advice into opts under a private key, and the nested run merges that advice over the child's own registries. Ancestor entries are outermost at equal depth; same-id ancestor entries replace child entries. Use advice_plan(wf, step) or advice_plan([parent, child], step) to inspect the composed stack.

7. blue.yml and validation gates

The CLI reads blue.yml by default (override with -f/--file), parses it with PyYAML plus YAML 1.2 core-schema boolean behavior, stamps the lifecycle event, and passes the resulting dict to run.

zk/workdir: work
zk/servers:
  - {id: 1, name: zk1, ip: 10.0.0.1}
  - {id: 2, name: zk2, ip: 10.0.0.2}
  - {id: 3, name: zk3, ip: 10.0.0.3}

# Quote version-like values; YAML would parse 3.10 as a number.
zk/version: "3.10"

schema_gate(schema, exit=2) builds a Pydantic-backed before-while advice. Gates validate and return the original opts; they must not replace opts with the parsed output because Pydantic can drop or transform unrelated namespace keys.

from typing_extensions import TypedDict
from blue.gates import schema_gate
from blue.workflow import advice_add

# Functional TypedDict syntax carries namespaced string keys.
Server = TypedDict("Server", {"id": int, "name": str})
State = TypedDict("State", {"zk/servers": list[Server]})

gated = advice_add(wf, "zk/start", "before-while",
                   "zk/state-schema", schema_gate(State))

8. The scaffolding DSL and renderer

scaffold(opts, specs) materializes or removes a flat sequence of file specs:

from pathlib import Path
from blue.scaffold import scaffold

main_tf = (Path(__file__).parent / "templates/main.tf").read_text()

specs = [{
    "template": {"name": "zk/main.tf", "content": main_tf},
    "target": "{{workdir}}/nodes/{{node.id}}/main.tf",
    "data": {"workdir": opts["zk/workdir"], "node": node},
}]

return scaffold(opts, specs)
KeyMeaning
template{"name", "content"}. The content should be read by the owning package, next to the module or via importlib.resources. There is no runtime template lookup.
targetOutput path, itself rendered with the same renderer against data.
dataTemplate data for both body and target path.
optsOptional render options, including delimiter overrides.

On "blue/event": "delete", the same specs name targets to remove. The implementation removes files and prunes the immediate parent directory if it becomes empty. Return keys are "blue.scaffold/written" or "blue.scaffold/deleted".

The renderer implements the Selmer-compatible subset blue exercises: variables, dotted paths, missing values as empty, HTML escaping by default, safe, sort(attribute='...'), for loops, and delimiter overrides (tag_open, tag_close, filter_open, filter_close). Delimiter overrides are useful when scaffolded Ansible files must preserve Jinja2 {{ }} and {% %}.

9. OpenTofu integration

from blue.tofu import local_backend_advice, s3_backend_advice, tofu_step


async def apply_node(opts: Opts) -> Opts:
    return await tofu_step(opts, dir=f"{opts['zk/workdir']}/node-{opts['zk/id']}")

Backends are not hardwired. backend_advice writes backend.tf for any backend type and flat config dict (or function of opts). Helpers ship for local, S3, and GCS.

wf = advice_add(
    wf,
    "zk/tofu",
    "before",
    "zk/backend",
    local_backend_advice(lambda opts: f"{opts['zk/workdir']}/node-{opts['zk/id']}"),
)

10. Ansible integration

from blue.ansible import ansible_step, inventory_advice


async def provision(opts: Opts) -> Opts:
    return await ansible_step(
        opts,
        dir="ansible",
        inventory="inventory.ini",
        private_key="work/ssh/id_ed25519",
        user="root",
        host_key_checking=False,
    )

11. Dry-run, progress, and runtime

Dry-run and progress are advice layers, not special cases in the engine.

from blue import dry_run, progress

wf2 = progress.advise(
    dry_run.advise(wf, ["zk/scaffold", "zk/tofu", "zk/ansible"])
)

12. The CLI and ./blue launcher

./blue <event> [-f|--file blue.yml] [--start step] [--end step] [--dry-run]

./blue create
./blue delete -f prod.yml
./blue create --start zk/node --end zk/node
./blue create --dry-run

A project's ./blue is a self-contained uv script: executable, with a #!/usr/bin/env -S uv run --script shebang and PEP 723 inline metadata. It owns the workflow definition, advice wiring, exec_cli call, and reads of local templates.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["blue"]
# ///
from blue import Opts, exec_cli, workflow


def start(opts: Opts) -> Opts:
    return {**opts, "app/seen": True}


def wire_fn(step: str, _run_opts: Opts):
    if step == "app/start":
        return (start,)


wf = workflow(start="app/start", wire_fn=wire_fn)

if __name__ == "__main__":
    exec_cli(wf)
Consumer imports. External consumers should pin blue, either as a released version such as blue==x.y.z or a pinned git reference. In-repo examples use PEP 723 [tool.uv.sources] with a relative path; do not recommend unpinned dependencies.

13. Examples

Each example is a self-contained uv CLI that imports this checkout by relative path and keeps desired state in blue.yml.

PathWhat it teaches
examples/zookeeperFake 3-node cluster: dynamic fan-out from desired state, join at zk/zoo-cfg, scaffolding plus OpenTofu, backend-as-advice, and dry-run.
examples/multi-zookeeperWorkflow composition: one cluster workflow embedded with step, fanned out once per cluster, with inherited parent advice.
examples/onceONCE-style single VPS with provider-swap advice, compute ∥ smtp → dns → smtp-post → (ansible-local ∥ ansible-remote), per-step tofu state, and scaffold-only Ansible files.
examples/multi-onceMany ONCE boxes from one workflow. The parent replaces inherited provider/backend advice; S3 state keys are isolated by deployment and step. Dry-run is offline.
examples/floci-zookeeperReal local ZooKeeper on floci: OpenTofu targets floci, Ansible provisions over SSH, delete routes through Ansible before destroys, validation gates and retry advice are used. Non-dry-run needs Linux, Docker, floci, tofu, ansible-playbook, docker, and aws.
cd examples/zookeeper
./blue create --dry-run
./blue create
./blue delete

cd ../multi-zookeeper
./blue create --dry-run
./blue create
./blue delete

cd ../once
./blue create --dry-run
./blue create
./blue delete

cd ../multi-once
./blue create --dry-run   # offline path; real create needs an S3 bucket
./blue create
./blue delete

cd ../floci-zookeeper
./blue create --dry-run   # offline path; real create needs Linux + Docker + floci
./blue create
./blue delete

14. Testing strategy

The suite runs with uv run pytest. Test files are discovered under tests/.

15. Project layout & API index

blue/
├── pyproject.toml       uv/Python package metadata and dependencies
├── uv.lock              Locked dependency graph
├── README.md            User-facing overview
├── index.html           This specification
├── src/blue/
│   ├── advice.py        nadvice combinators and pure registry ops
│   ├── workflow.py      workflow, run, step, advice add/remove/all, advice_plan
│   ├── renderer.py      small internal Selmer-compatible renderer
│   ├── scaffold.py      flat file-spec DSL over text-read templates
│   ├── tofu.py          tofu_step, outputs, backend advices (local/s3/gcs)
│   ├── ansible.py       ansible_step, ansible_with_spec, recap parsing, inventory advice
│   ├── dry_run.py       dry-run advice + advise
│   ├── progress.py      progress advice + advise
│   ├── cli.py           run_cli, exec_cli, arg parsing, YAML loader
│   ├── gates.py         Pydantic schema gates
│   ├── runtime.py       mutable subprocess/log seam
│   └── __init__.py      root re-exports
├── tests/               advice, workflow, scaffold, cli, dry-run, progress, tofu, ansible, gates, zookeeper
└── examples/            zookeeper, multi-zookeeper, once, multi-once, floci-zookeeper

Distribution

Library package name: blue. Version in this checkout: 0.1.0.

Function / exportSignature / purpose
workflowworkflow(start, wire_fn, end=None, next_fn=None) -> Workflow; wire_fn is (step, run_opts) -> (fn, *next).
runawait run(workflow, opts) -> Opts; runs the scheduler and returns final opts.
stepstep(workflow, in_=None, out=None) -> StepFn; embeds a workflow as an ordinary step.
StepErrorError subclass with exit; raise to choose a step failure exit code.
failedfailed(opts) -> bool; true when "blue/exit" is positive.
advice_add(workflow, step, how, id, fn, props=None) -> Workflow.
advice_remove(workflow, step, id) -> Workflow.
advice_add_all(workflow, how, id, fn, props=None) -> Workflow.
advice_remove_all(workflow, id) -> Workflow.
advice_plan(workflow_or_chain, step) -> list[dict]; printable advice stack.
advice.HOWS, advice.ordered, advice.composeLow-level advice combinator set, ordering, and wrapper composition.
advice.add, advice.remove_id, advice.add_global, advice.remove_global_idLow-level pure registry operations used by blue.workflow.
advice.merge_entries, advice.merge_registryLow-level inheritance helpers for composing parent/child advice registries.
render(template, data, opts=None) -> str; render a template string.
render_template(template, data, opts=None) -> str; render a {"name", "content"} template dict.
scaffold(opts, specs) -> opts; create or delete rendered targets.
tofu.outputsawait outputs(dir) -> dict; parse tofu output -json.
tofu.tofu_stepawait tofu_step(opts, dir=..., output_key="tofu/outputs") -> Opts.
tofu.backend_advice(dir_fn, type, config) -> before_advice; write backend.tf.
tofu.local_backend_advice(dir_fn, config=None) -> before_advice.
tofu.s3_backend_advice(dir_fn, config) -> before_advice.
tofu.gcs_backend_advice(dir_fn, config) -> before_advice.
ansible.default_playbooksDefault event-to-playbook map: {"create": "create.yml", "delete": "delete.yml"}.
ansible.playbook(opts, playbooks=None) -> str; select create/delete playbook.
ansible.parse_recap(ansible_output) -> per_host_counters.
ansible.ansible_stepawait ansible_step(opts, **config) -> Opts.
ansible.ansible_with_specawait ansible_with_spec(opts, specs, **config) -> Opts; scaffold/run ordering for Ansible.
ansible.inventory_ini(groups) -> str; deterministic INI renderer.
ansible.inventory_advice(file_fn, groups) -> before_advice; write inventory before a step.
dry_run.advice(step) -> around_advice.
dry_run.advise(workflow, steps) -> Workflow.
progress.progressAll-step around advice that logs entry/exit timing.
progress.advise(workflow) -> Workflow.
schema_gate(pydantic_schema, exit=2) -> before_while_advice; validate without transforming opts.
USAGECLI usage string.
load_yaml(text) -> Opts; YAML 1.2 core-schema boolean loader.
run_cliawait run_cli(workflow, args) -> Opts; non-exiting CLI runner.
exec_cliexec_cli(workflow, args=None) -> None; print errors and exit process.
ExecResultDataclass result from subprocess execution: {exit, out, err}.
runtimeMutable {exec, log} seam used by subprocess and logging helpers.