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.
struct boot : flow::service<"boot"> {}; The type every component extends, and the name it can be found by.cib::extend<boot>(*A >> *B): steps to add (*) and orderings (>>) between steps.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"_refin one file andclocks::READYin 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.
*stepadds it; a baresteponly 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
#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
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
| Write | What 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"_action | A 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"_milestone | A no-op anchor for ordering. Named flows log it as flow.milestone(n). |
// 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
| Operator | Meaning | Example |
|---|---|---|
*x | Add x to the flow. Exactly once per flow, anywhere in the project. | *CLOCKS |
x / "n"_ref | Refer to a step for ordering. _ref needs no include of the owner. | "hw_ready"_ref |
a >> b | a runs before b. Chains; every final step of a precedes every initial step of b. | *A >> (*B && *C) >> *D |
a && b | Both, 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.
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.
Conditions
A condition wraps cib::extend calls. There are two kinds, and they differ in when the decision is made.
cib::constexpr_condition | cib::runtime_condition | |
|---|---|---|
| Decided | At compile time | Each time a guarded step is reached |
| When false | The steps aren’t in the flow; no code is generated | The steps stay in the sequence and are skipped |
| Predicate | A constexpr lambda | A default-constructible lambda (captureless, reads globals) |
| Use for | Board and product variants | Feature flags, configuration read at boot |
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.
when_a(cib::extend<F>(*A)),
when_b(cib::extend<F>(*B)),
(when_a and when_b)(cib::extend<F>(A >> B)) | when_a | when_b | runs |
|---|---|---|
| true | true | A B |
| true | false | A |
| false | true | B |
| false | false | — |
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 iscall to deleted function 'make_runtime_conditional'. At global scope it always works. A condition guarding a single step (*A) works from anywhere. - No
_refinside them.when_c(cib::extend<F>("a"_ref >> *b))fails withno 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
| Call | How it reaches the flow | Before 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:
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.startandflow.end. - Milestones log as
flow.milestone(name).
Override the level by specializing flow::log_env. It’s looked up in this order:
- The step’s name:
flow::log_env<"lvl"> - The flow’s name:
flow::log_env<"leveled"> flow::log_env<"default">- The built-in
logging::level::TRACE
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.
#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>(); ---
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:
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
| Library | Coupling | What passes between them |
|---|---|---|
| nexus | link | Flows are nexus services. cib::config, exports, components, extend and both condition kinds come from nexus. init() wires flow::run. |
| log | link | Named flows log through logging::config<>. Levels come from flow::log_env. |
| cib::top | flows | Its four phases are flows you extend. |
| interrupt | flows | IRQ configs name flows as payloads, and a flow’s active flag decides whether an IRQ exists. |
| seq | builds on | Reuses the flow DSL and topological sort with resumable, reversible steps. |
| callback | alternative | A simpler service: handlers take arguments and have no ordering. |
| stdx | link | 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:
cib::EarlyRuntimeInit: runs once, first; for essential services such as loggingcib::RuntimeInit: general component initializationcib::RuntimeStart: enable interrupts, start threadscib::MainLoop: runs repeatedly, forever
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:
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.
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.
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
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 see | Cause | Fix |
|---|---|---|
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 referenced | Add 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 name | One *; unique names |
Topological sort failed: cycle in flow.Unresolved edges involved in cycle:a -> bb -> a | Contradictory orderings | The 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 steps | Guard 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 condition | Use the step’s constant |
Trying to extend a service (…) that is not exported | No 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 initialized | Runtime panic: flow::run before init() | Call init() first, or use Nexus::service<F>() |
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Steps run in a different order after adding a component | Their order was never constrained | Write the >> you depend on |
| A flow “does nothing” at boot | It ran before init(), and the default panic handler is a no-op | Install a panic handler; check init order |
| Lambda won’t compile as an action | It captures | Use globals or statics, or a nexus-injected action |
| A feature step runs half-way through a flow | The flag changed mid-flow; predicates are read per step | Set flags before the flow starts |
generate_flow_graph produces nothing | mmdc not found, or the flow is unnamed | Install mermaid-cli; name the flow |
Viz service breaks init() | Its interface returns string_view | Put it in a separate nexus |
| Log level override ignored | The specialization was seen after the flow was built | Declare flow::log_env specializations early, in a shared header |
| ISR latency from logging | A named flow runs inside the ISR | Use flow::log_policies::none |
| “explicit specialization after instantiation” | cib_func defined after the nexus was instantiated in the same TU | Define it in another TU, or declare it earlier |