Skip to content
michael.caisse.io michael.caisse.io

CIB Guide Chapter 2 of 8

Flow

How independent components add ordered steps to shared flows, and how cib merges them into one dependency graph and sorts it into a plain call sequence at compile time.

Headers
include/flow/
Namespace
flow
Standard
C++23 (main)
Links
cib_nexus · cib_log · stdx
Cooperates
cib::top · interrupt · seq

How flow works

A flow is a named extension point. Components never call each other to join a sequence. Each one contributes a small fragment that says which steps it adds and what they must come after or before. cib collects every fragment for a flow, merges them into one directed graph by step name, and sorts that graph at compile time. At runtime, running the flow is a flat list of calls.

Declared once Service
struct boot : flow::service<"boot"> {}; The type every component extends, and the name it can be found by.
Many components Fragments
cib::extend<boot>(*A >> *B): steps to add (*) and orderings (>>) between steps.
Compile time → runtime Built flow
A topologically sorted, inlined sequence of step calls. There’s no graph, allocation, or sorting at runtime.
FRAGMENTS clocks: *CLOCKS >> *READY clocks hw_ready uart: CLOCKS >> *UART >> READY clocks uart hw_ready sensors: "hw_ready"_ref >> *SENSORS hw_ready sensors merge by name ONE GRAPH clocks uart hw_ready sensors sort at compile time WHAT run() DOES clocks(); uart(); // hw_ready sensors();
Dashed boxes are references: a step mentioned for ordering but added by another component. The uart component inserts itself between two steps it doesn’t own. Milestones (rounded) run no code; named flows log them.

Three rules follow from this design:

  • Steps are identified by name. "hw_ready"_ref in one file and clocks::READY in another are the same node. Two different lambdas with the same name are also the same node, which is a duplicate-add error.
  • Each step is added exactly once. *step adds it; a bare step only refers to it. The whole project must contain exactly one * per step, per flow.
  • Only the orderings you write exist. Steps with no path between them run in an unspecified order.

Quick start

boot.cpp two components, one flow
#include <cib/cib.hpp>   // flow, nexus, cib::config/extend/exports/components

// 1. The flow: an extension point with a name.
struct boot : flow::service<"boot"> {};

// 2. A component adds two steps and orders them.
struct clocks {
    constexpr static auto CLOCKS = flow::action<"clocks">([] { clock_tree_init(); });
    constexpr static auto READY  = flow::milestone<"hw_ready">();

    constexpr static auto config = cib::config(cib::extend<boot>(*CLOCKS >> *READY));
};

// 3. Another component adds a step after a milestone it doesn't own.
using namespace flow::dsl::literals;   // "..."_ref
struct sensors {
    constexpr static auto SENSORS = flow::action<"sensors">([] { sensors_init(); });

    constexpr static auto config =
        cib::config(cib::extend<boot>("hw_ready"_ref >> *SENSORS));
};

// 4. The project exports the flow and lists the components.
struct project {
    constexpr static auto config = cib::config(
        cib::exports<boot>, cib::components<clocks, sensors>);
};
using nexus_t = cib::nexus<project>;

int main() {
    nexus_t::init();      // wires flow::run<boot> and cib::invoke_service<"boot">
    flow::run<boot>();    // clocks(), then sensors()
}

Neither component includes the other. sensors knows only the flow and the milestone’s name.

Building blocks

Services

three kinds of declaration
struct boot    : flow::service<"boot"> {};                             // named: logs start, steps, end
struct isr_rx  : flow::service<"isr_rx", flow::log_policies::none> {}; // named, never logs
struct scratch : flow::service<> {};                                   // unnamed: never logs

The name lets other code extend the flow without its type (cib::extend<"boot">), run it by name (cib::invoke_service<"boot">()), and target it with generate_flow_graph. An unnamed flow works too, but can’t be found in any of those ways.

Actions and milestones

WriteWhat it is
flow::action<"n">([] { … })A step with a body. The lambda must be captureless. Keep state in globals, statics, or objects reachable through the nexus.
flow::action<"n">([]<typename Nexus>() { … })The body receives the nexus type it was built into, and can run other flows with Nexus::template service<F>().
flow::step<"n">(), "n"_step, "n"_actionA name-only step. Its body is template <> auto cib_func<"n">() -> void { … }, usually in another translation unit. A missing definition is a link error.
flow::milestone<"n">(), "n"_milestoneA no-op anchor for ordering. Named flows log it as flow.milestone(n).
separating the graph from the implementation
// selftest_component.hpp: shape only
using namespace flow::literals;
struct selftest_component {
    constexpr static auto config = cib::config(cib::extend<boot>(
        "hw_ready"_ref >> *"selftest"_step >> *"report"_step));
};

// selftest.cpp: bodies
#include <nexus/func_decl.hpp>
template <> auto cib_func<"selftest">() -> void { run_selftest(); }
template <> auto cib_func<"report">() -> void { report_results(); }

Operators

OperatorMeaningExample
*xAdd x to the flow. Exactly once per flow, anywhere in the project.*CLOCKS
x / "n"_refRefer to a step for ordering. _ref needs no include of the owner."hw_ready"_ref
a >> ba runs before b. Chains; every final step of a precedes every initial step of b.*A >> (*B && *C) >> *D
a && bBoth, with no order between them. Leaves room for others to insert.*APP && *LEDS
*(…)Add every step mentioned in the subgraph.*(A >> B >> C)

_ref lives in flow::dsl::literals. _action, _step and _milestone live in flow::literals. cib::extend takes any number of fragments, and a component may call it as often as it likes.

Composing across components

The pattern that scales is to publish milestones as a flow’s public API. The clock component owns hw_ready. Every driver or application component orders itself against that name, and nobody includes anybody’s header.

four components extending one flow
struct uart {      // inserts between two steps owned by clocks
    constexpr static auto UART = flow::action<"uart">([] { uart_init(); });
    constexpr static auto config = cib::config(
        cib::extend<boot>(clocks::CLOCKS >> *UART >> clocks::READY));
};

struct app {       // two steps after hw_ready, unordered with each other
    constexpr static auto APP  = flow::action<"app">([] { app_start(); });
    constexpr static auto LEDS = flow::action<"leds">([] { leds_on(); });
    constexpr static auto config = cib::config(
        cib::extend<"boot">("hw_ready"_ref >> (*APP && *LEDS)));
};

With clocks, uart, sensors, app and a two-step self-test, the verification build produced clocks uart selftest report leds app sensors. clocks and uart are guaranteed to come first, and report after selftest. The relative order of everything else after hw_ready is up to the library.

Graph bench

Toggle components to see the merged graph, one valid run order, and what the compiler would say. Three components carry a deliberate bug. The diagnostic texts are the library’s own.

boot · merged graph

Checks run in the library's order: missing and duplicate steps first, then the topological sort.

Components

Conditions

A condition wraps cib::extend calls. There are two kinds, and they differ in when the decision is made.

cib::constexpr_conditioncib::runtime_condition
DecidedAt compile timeEach time a guarded step is reached
When falseThe steps aren’t in the flow; no code is generatedThe steps stay in the sequence and are skipped
PredicateA constexpr lambdaA default-constructible lambda (captureless, reads globals)
Use forBoard and product variantsFeature flags, configuration read at boot
both kinds declare runtime conditions at global scope
template <bool On>
constexpr auto with_display =
    cib::constexpr_condition<"with_display">([] { return On; });

inline bool telemetry_enabled = false;
constexpr auto when_telemetry =
    cib::runtime_condition<"telemetry">([] { return telemetry_enabled; });

struct display {
    constexpr static auto config = cib::config(
        with_display<BOARD_HAS_DISPLAY>(cib::extend<boot>("hw_ready"_ref >> *DISPLAY)));
};
struct telemetry {
    constexpr static auto config = cib::config(
        when_telemetry(cib::extend<boot>(*T_INIT)));
};

Ordering conditional steps

An ordering between two conditional steps must carry at least both of their conditions. Otherwise the library would have to enforce an order involving a step that might not run.

from test/flow/flow.cpp
when_a(cib::extend<F>(*A)),
when_b(cib::extend<F>(*B)),
(when_a and when_b)(cib::extend<F>(A >> B))
when_awhen_bruns
truetrueA B
truefalseA
falsetrueB
falsefalse

Leave out a condition and the build stops with The conditions on the sequence (a >> b)[always] are weaker than those on a[feature_a_enabled] or b[always]. Specifically, the sequence is missing the predicate: …. An ordering may carry more conditions than its steps. It then applies only when all of them hold.

Runtime conditions in practice

  • Evaluated per step, per run. The predicate runs every time a guarded step is reached. The value isn’t captured at the start of a run: in the verification, one flag guarding three steps was evaluated three times in one run. A flag that changes mid-flow affects the later steps. Keep predicates cheap and free of side effects.
  • Declare them at global namespace scope. A runtime condition inside a namespace that guards >> or && fails whenever the steps are also namespaced or name-only. The error is call to deleted function 'make_runtime_conditional'. At global scope it always works. A condition guarding a single step (*A) works from anywhere.
  • No _ref inside them. when_c(cib::extend<F>("a"_ref >> *b)) fails with no matching function for call to 'make_runtime_conditional'. Refer to the step’s constant instead.
  • Conditions compose with and, and nested conditions (when_x(when_y(…))) require both.

Running flows

CallHow it reaches the flowBefore init()
flow::run<F>()
cib::service<F>()
Through a function pointer that nexus::init() fills in: an indirect call from anywhere, with no nexus type needed.Calls stdx::panic: Attempting to run flow (F) before it is initialized
Nexus::service<F>()Directly calls the built flow, which the compiler can inline. Needs the nexus type.Works
cib::invoke_service<"F">()By name, through a pointer set by init().Panics
Nexus-injected action[]<typename Nexus>() { Nexus::template service<G>(); }: a direct call from inside another flow.Works

Flows are void(): no arguments and no result. Pass data through shared state. A built flow also reports constexpr static bool active, which is true when it has at least one step: nexus_t::service_v<F>.active.

Logging

A named flow logs through cib’s logging library at TRACE by default:

log_fmt output from the verification build, with the level overridden to INFO
172us INFO [default]: flow.start(leveled)
175us INFO [default]: flow.action(lvl)
177us INFO [default]: flow.end(leveled)
  • Unnamed flows, and flows with flow::log_policies::none, log nothing.
  • A named flow with no steps still logs flow.start and flow.end.
  • Milestones log as flow.milestone(name).

Override the level by specializing flow::log_env. It’s looked up in this order:

  1. The step’s name: flow::log_env<"lvl">
  2. The flow’s name: flow::log_env<"leveled">
  3. flow::log_env<"default">
  4. The built-in logging::level::TRACE
must be visible before the flow is instantiated
using info_env = stdx::extend_env_t<flow::default_log_env,
                                    logging::get_level, logging::level::INFO>;
template <> constexpr auto flow::log_env<"leveled"> = info_env{};

Seeing the graph

Render a whole flow

flow::viz_builder turns the same fragments into Graphviz or Mermaid text. A viz service returns a std::string_view, so it can’t be in a config that goes through init(). Give it its own nexus.

separate nexus for visualization
#include <flow/viz_builder.hpp>

struct boot_viz
    : flow::service_for<flow::builder_for<flow::viz_builder<"boot_viz", flow::mermaid>>> {};

struct viz_project {
    constexpr static auto config = cib::config(
        cib::exports<boot_viz>,
        cib::extend<boot_viz>(*clocks::CLOCKS >> *uart::UART),
        when_feature(cib::extend<boot_viz>(uart::UART >> *FEAT1)));
};

constexpr auto text = cib::nexus<viz_project>::service<boot_viz>();
verified output
---
title: boot_viz
---
flowchart TD
  _start((start))
  _end((end))
  _uart(uart)
  _feat1(feat1)
  _clocks(clocks)
  _uart --feature--> _feat1
  _clocks --> _uart
  _start --> _clocks
  _feat1 --feature--> _end

Runtime conditions label their edges. flow::graphviz is the default renderer.

Render the part between two steps

flow::debug_builder<"b", "c", flow::mermaid> renders only the subgraph between two named steps, and reports it through a static_assert, so the build stops with the graph in the compiler output. The CMake helper automates this:

CMakeLists.txt
generate_flow_graph(TARGET firmware FLOW_NAME boot START_NODE clocks END_NODE sensors)
# builds target firmware.boot.graph → firmware.boot.graph.mmd and .svg

It injects a header that specializes flow::service<"boot", LogPolicy>, so it only works for flows declared with that exact name. It also needs mmdc (mermaid-cli) on PATH. Without it the function does nothing beyond printing a CMake status message.

Working with the rest of cib

LibraryCouplingWhat passes between them
nexuslink Flows are nexus services. cib::config, exports, components, extend and both condition kinds come from nexus. init() wires flow::run.
loglink Named flows log through logging::config<>. Levels come from flow::log_env.
cib::topflows Its four phases are flows you extend.
interruptflows IRQ configs name flows as payloads, and a flow’s active flag decides whether an IRQ exists.
seqbuilds on Reuses the flow DSL and topological sort with resumable, reversible steps.
callbackalternative A simpler service: handlers take arguments and have no ordering.
stdxlink ct_string names, panic_handler, env-based log levels.

cib::top phases

cib::top<Project>::main() calls init(), then runs four flows that top exports itself:

  1. cib::EarlyRuntimeInit: runs once, first; for essential services such as logging
  2. cib::RuntimeInit: general component initialization
  3. cib::RuntimeStart: enable interrupts, start threads
  4. cib::MainLoop: runs repeatedly, forever
a component hooking the phases
struct day_cycle {
    constexpr static auto TICK = flow::action<"day_tick">([] {
        flow::run<morning>();   // your own flows, run from a phase
        flow::run<evening>();
    });
    constexpr static auto config = cib::config(
        cib::exports<morning, evening>,
        cib::extend<cib::MainLoop>(*TICK));
};

cib::top<my_project> top{};
int main() { top.main(); }

The four phases are unnamed flow::service<> types, so they don’t log their steps. top logs its own phase transitions with CIB_INFO.

interrupt

An interrupt configuration lists flows to run when an IRQ fires. If no component extends a flow, the IRQ is initialized disabled and its dispatch code compiles away. Declare ISR flows named but silent, so configuration dumps stay readable and nothing logs inside the ISR:

ISR payload
struct uart0_rx : flow::service<"uart0_rx", flow::log_policies::none> {};
// interrupt::sub_irq<"uart0_rx", RXIE, RXI, interrupt::policies<>, uart0_rx>

seq: sequences that can pause and reverse

seq uses flow’s operators and sort, but each step has a forward and a backward function returning seq::status. A step that returns NOT_DONE is called again on the next forward(). backward() unwinds in reverse, first finishing an in-progress forward step.

verified
auto const power_on = seq::step<"power_on">(
    []() -> seq::status { return rail_up() ? seq::status::DONE : seq::status::NOT_DONE; },
    []() -> seq::status { rail_down(); return seq::status::DONE; });
auto const clock_on = seq::step<"clock_on">(/* forward */, /* backward */);

auto const g = seq::builder<>{}.add(*power_on >> *clock_on);
auto [power, leftover] =
    flow::graph_builder<"power", flow::log_policies::none, seq::impl>::build(g);

power.forward();    // power_on, clock_on   (repeat until DONE)
power.backward();   // clock_on, power_on in reverse

Build seq directly as shown, following test/seq. seq::service doesn’t provide the uninitialized() hook a nexus service needs.

callback: when order doesn’t matter

callback::service<Args...> collects handlers that take arguments and are all called, with no ordering between them. Use it for notifications such as “byte received”. Use a flow when steps have prerequisites, when other components need to insert between them, or when you want the sequence logged.

Use cases

Boot with published phases

The platform component owns a flow of milestones (clocks_ready, buses_ready, services_ready). Drivers order against the earlier phases and applications against the later ones. A new driver joins the build by being added to cib::components, with no edits to the platform.

platform.hpp
struct platform {
    constexpr static auto config = cib::config(
        cib::exports<boot>,
        cib::extend<boot>(*"clocks_ready"_milestone >> *"buses_ready"_milestone
                          >> *"services_ready"_milestone));
};
// drivers: "clocks_ready"_ref >> *I2C_INIT >> "buses_ready"_ref
// apps:    "services_ready"_ref >> *APP_START

Power-state transitions

Declare one flow per transition (enter_sleep, exit_sleep). Each peripheral adds its save step to one and its restore step to the other, ordered against milestones such as "clocks_gated". For transitions that must wait on hardware or roll back on failure, use seq (§9.3).

One source tree, many products

Wrap optional steps in constexpr_conditions driven by board constants. Steps that are compiled out cost nothing, and an interrupt whose flows end up empty is disabled.

Feature flags loaded at boot

Read configuration in an early step, set globals, and guard later steps with runtime_conditions declared at global scope. Because predicates are read at each step, set the flags before the first guarded step runs.

Library extension points

A reusable library can export a flow (on_link_up) with a couple of milestones, and let applications add steps. The library never needs to know about them.

Unit-testing a graph without a nexus

render fragments directly (verified)
template <auto... Fragments> struct fragments_of {
    constexpr static auto value = flow::builder<>{}.add(Fragments...);
};
// parentheses keep >> from closing the template argument list
flow::graph_builder<"direct">::render<fragments_of<(*clocks::CLOCKS >> *uart::UART)>>()();

Record steps into a string. Assert exact order only where >> guarantees it, and assert membership plus pairwise order for the rest.

Diagnostics

Captured from clang 21 on the repo’s compile-fail tests and additional probes:

You’ll seeCauseFix
One or more steps are referenced in the flow (alpha) but not explicitly added with the * operator. The missing steps are: a.A step is only referencedAdd it with * in exactly one place
One or more steps in the flow (alpha) are explicitly added more than once using the * operator. The duplicate steps are: a.Two * for one name, or two different actions with the same nameOne *; unique names
Topological sort failed: cycle in flow.
Unresolved edges involved in cycle:
a -> b
b -> a
Contradictory orderingsThe edges listed include the cycle
The conditions on the sequence (a >> b)[always] are weaker than those on a[feature_a_enabled] or b[always]…An ordering has weaker conditions than its stepsGuard it with (ca and cb)
call to deleted function 'make_runtime_conditional'A namespaced runtime condition guarding >>/&&Declare it at global scope
no matching function for call to 'make_runtime_conditional'_ref inside a runtime conditionUse the step’s constant
Trying to extend a service (…) that is not exportedNo cib::exports<F>Export the flow
undefined reference to cib_func<…>A name-only step has no body (link error)Define cib_func<"name">
Attempting to run flow (F) before it is initializedRuntime panic: flow::run before init()Call init() first, or use Nexus::service<F>()

Pitfalls

SymptomCauseFix
Steps run in a different order after adding a componentTheir order was never constrainedWrite the >> you depend on
A flow “does nothing” at bootIt ran before init(), and the default panic handler is a no-opInstall a panic handler; check init order
Lambda won’t compile as an actionIt capturesUse globals or statics, or a nexus-injected action
A feature step runs half-way through a flowThe flag changed mid-flow; predicates are read per stepSet flags before the flow starts
generate_flow_graph produces nothingmmdc not found, or the flow is unnamedInstall mermaid-cli; name the flow
Viz service breaks init()Its interface returns string_viewPut it in a separate nexus
Log level override ignoredThe specialization was seen after the flow was builtDeclare flow::log_env specializations early, in a shared header
ISR latency from loggingA named flow runs inside the ISRUse flow::log_policies::none
“explicit specialization after instantiation”cib_func defined after the nexus was instantiated in the same TUDefine it in another TU, or declare it earlier