Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
80b2840
chore(model): rename NodeKind variants for clarity
senamakel Aug 13, 2026
dffe7ff
fix(model): correct node kind test expectations
senamakel Aug 13, 2026
f1a9887
fix(control_flow): restore void node behavior
senamakel Aug 13, 2026
0ce7ae4
fix(control_flow): restore void test coverage
senamakel Aug 13, 2026
a4b085b
chore(control_flow): add missing newline at end of file
senamakel Aug 13, 2026
771cf2d
chore(control_flow): add missing newline at end of file
senamakel Aug 13, 2026
aace334
chore(control_flow): add missing newline at end of file
senamakel Aug 13, 2026
529c145
fix(execution): restore node execution after failed state transition
senamakel Aug 13, 2026
14bec15
chore(visualization): remove unused import
senamakel Aug 13, 2026
c2e13e6
fix(tests): restore missing test module
senamakel Aug 13, 2026
e389b1c
fix(tests): restore missing test module
senamakel Aug 13, 2026
b33f55d
fix(validate): restore missing null check in validator
senamakel Aug 13, 2026
22e9719
fix(validate): restore missing null check in validator
senamakel Aug 13, 2026
b8bb1c9
fix(validate): restore scatter validation for empty inputs
senamakel Aug 13, 2026
8fc7f54
fix(validate): reject scatter with zero points
senamakel Aug 13, 2026
4eb1f2d
fix(validate): restore missing null check in validator
senamakel Aug 13, 2026
8509a39
fix(validate): restore missing null check in validator
senamakel Aug 13, 2026
895b0ab
fix(validate_tests): restore missing test coverage for part 04
senamakel Aug 13, 2026
35b0ee9
fix(validate): restore missing test assertions
senamakel Aug 13, 2026
b3da3b3
feat(catalog): add void node contract
senamakel Aug 13, 2026
51c5afd
fix(catalog): add void node kind to catalog
senamakel Aug 13, 2026
1703b18
fix(catalog): restore group_02 contract definitions
senamakel Aug 13, 2026
36f823f
test(catalog): update node kind count to 21
senamakel Aug 13, 2026
0011499
chore: add fan-out contract tests
senamakel Aug 13, 2026
bba4aec
fix(catalog): restore missing catalog tests
senamakel Aug 13, 2026
5cc461e
fix(test): add void node tests
senamakel Aug 13, 2026
9116735
fix(test): add void node tests
senamakel Aug 13, 2026
9917b71
test(smoke): add smoke test for void node
senamakel Aug 13, 2026
95acdad
docs(readme): document the void node in the node catalog
senamakel Aug 13, 2026
c37640e
docs(wiki): add Node Catalog page
senamakel Aug 13, 2026
7d03d57
docs(changelog): document the new void node kind
senamakel Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **New node kind: `void`, a terminal sink.** It accepts items on `main`,
discards them, and activates nothing — the branch ends there, on purpose. A
branch could always dead-end (a node with no outgoing edges terminates), but
an unwired port reads exactly like a forgotten one, so there was no way to
declare "this is a side effect and nothing waits on it". `spawn → void` is now
the explicit spelling of a ticket no `gate` will collect; the abandon
semantics are unchanged from leaving it unwired. It adds **no** concurrency:
work upstream still runs inline in its own super-step, and only the result is
dropped. Its slot is `{items: [], port: null, discarded: N}`, which keeps
"never activated" (no slot at all), "activated with nothing to drop" and
"dropped N items" distinguishable.

Validation refuses a `void` with any outgoing edge — including the `error`
edge `on_error: "route"` would otherwise demand, which is reported directly
rather than as a `MissingErrorRoute` the next rule would then reject — and one
with no incoming edge, since a node with no effect and no input declares
nothing.

The scatter-lane dead-end rule is relaxed accordingly: a lane branch ending in
a `void` is now legal, making it the one dead end a lane may have. The rule
exists to catch *accidental* invisibility (a lane activation never writes the
node's top-level slot), and a `void` is the author declaring it. A `scatter`
with no `gather` anywhere is still refused, void downstream or not.

Not included, deliberately: no lint for a `spawn` with neither a `gate` nor a
`void` downstream. `validate_all` has no severity tier, and making it a hard
error would break the documented "fire-and-forget is legal" contract. A
possible future addition if a warning channel ever lands.

- **Configurable agents.** An `agent` node can now be given dynamic context,
an explicit tool allow-list, a model and provider, a working directory,
advisory limits, and arbitrary harness metadata — while the agent
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ done
| `merge` | Fan-in barrier that combines multiple inputs; waits for all wired predecessors. |
| `split_out` | Fan-out that emits one item per element of a list. |
| `transform` | Pure, expression-based data transform / field mapping over the run state. |
| `void` | Terminal sink: discards its input and runs nothing downstream — the explicit dead end. |

See the [Node Catalog](../../wiki/Node-Catalog) wiki page for config keys and
ports.
Expand Down
4 changes: 3 additions & 1 deletion src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ use group_03::*;

/// The node kinds, in the canonical order used wherever the DSL is enumerated
/// (matches [`NodeKind`](crate::model::NodeKind)'s serde discriminators).
pub const NODE_KINDS: [&str; 20] = [
pub const NODE_KINDS: [&str; 21] = [
"trigger",
"agent",
"tool_call",
Expand All @@ -54,6 +54,7 @@ pub const NODE_KINDS: [&str; 20] = [
"gate",
"scatter",
"gather",
"void",
];

/// One config field a node of a given kind reads at run time.
Expand Down Expand Up @@ -204,6 +205,7 @@ pub fn contract_for(kind: &str) -> Option<NodeKindContract> {
"gate" => contract_gate(),
"scatter" => contract_scatter(),
"gather" => contract_gather(),
"void" => contract_void(),
_ => return None,
};
Some(with_fan_out_fields(c))
Expand Down
5 changes: 3 additions & 2 deletions src/catalog/contracts/group_02.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,9 @@ pub(super) fn contract_spawn() -> NodeKindContract {
the same, the concurrency is not. That is a silent performance cliff, so check \
the host wires a TaskRunner before relying on overlap."
.to_string(),
"Fire-and-forget is legal — a spawn no gate ever collects simply runs. If that \
is not what you meant, wire a `gate`."
"Fire-and-forget is legal — a spawn no gate ever collects simply runs. Wire the \
spawn into a `void` to say that on purpose; wire it into a `gate` if you \
actually wanted the results."
.to_string(),
],
}
Expand Down
46 changes: 46 additions & 0 deletions src/catalog/contracts/group_03.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,49 @@ pub(super) fn contract_gather() -> NodeKindContract {
],
}
}

pub(super) fn contract_void() -> NodeKindContract {
NodeKindContract {
kind: "void".to_string(),
summary: "Terminal sink: accepts items, discards them, and runs nothing downstream."
.to_string(),
description: "The branch ends here, on purpose. A branch could always dead-end — a \
node with no outgoing edges terminates — but an unwired port reads \
exactly like a forgotten one, so there was no way to SAY \"this is a \
side effect and nothing waits on it\". This node is that sentence. It \
adds no concurrency: the work upstream still runs inline in its own \
super-step, and only the result is dropped. For work that should \
actually overlap, use `spawn`."
.to_string(),
config_fields: vec![],
ports: PortSpec::new(&["main"], &[]),
example: json!({
"id": "drop", "kind": "void", "name": "Fire and forget: audit log",
"config": {}
}),
notes: vec![
"An outgoing edge is a validation error, and so is having no incoming edge. \
`on_error: \"route\"` is refused for the same reason — an `error` edge is an \
outgoing edge; use \"stop\" or \"continue\"."
.to_string(),
"It writes {items: [], port: null, discarded: N} into its slot. A node that never \
ran has no slot at all, so \"never activated\", \"activated with nothing to \
drop\" (discarded: 0) and \"dropped N items\" stay distinguishable."
.to_string(),
"`discarded` counts THIS activation, not a running total: inside a loop body the \
last iteration's value is what survives, and inside a scatter lane it lands \
under lanes.<lane> rather than at the top level."
.to_string(),
"spawn -> void is the explicit spelling of a ticket no `gate` will ever collect. \
Same abandon semantics as leaving it unwired, but said out loud."
.to_string(),
"It is the one dead end a scatter lane may have. Every other lane branch must \
reach the `gather`, because a stranded lane's output is invisible rather than \
merely uncollected — a void makes that invisibility the contract."
.to_string(),
"There is no `reason` config. The node's `name` is where the human explanation \
goes, and unlike a config key it is rendered in the graph."
.to_string(),
],
}
}
29 changes: 26 additions & 3 deletions src/catalog_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ fn every_node_kind_has_a_contract() {
}
}
}
assert_eq!(all_contracts().len(), 20);
assert_eq!(all_contracts().len(), 21);
}

#[test]
Expand All @@ -48,8 +48,8 @@ fn unknown_kind_has_no_contract() {
}

#[test]
fn node_kinds_has_20_entries_including_the_async_and_lane_pairs() {
assert_eq!(NODE_KINDS.len(), 20);
fn node_kinds_has_21_entries_including_the_async_and_lane_pairs() {
assert_eq!(NODE_KINDS.len(), 21);
assert!(NODE_KINDS.contains(&"shell"));
assert!(NODE_KINDS.contains(&"memory"));
assert!(NODE_KINDS.contains(&"dedup"));
Expand All @@ -67,6 +67,29 @@ fn node_kinds_has_20_entries_including_the_async_and_lane_pairs() {
assert_eq!(NODE_KINDS[17], "gate");
assert_eq!(NODE_KINDS[18], "scatter");
assert_eq!(NODE_KINDS[19], "gather");
assert!(NODE_KINDS.contains(&"void"));
assert_eq!(NODE_KINDS[20], "void");
}

#[test]
fn void_contract_takes_no_config_and_declares_no_output_port() {
// The two claims an authoring tool acts on: there is nothing to configure,
// and there is nowhere to draw an edge to. Both are enforced by validation,
// so the contract must not suggest otherwise.
let c = contract_for("void").expect("void contract exists");
assert!(
c.config_fields.is_empty(),
"void takes no config; the reason goes in the node's name"
);
assert_eq!(c.ports, PortSpec::new(&["main"], &[]));
assert!(
c.notes.iter().any(|n| n.contains("outgoing edge")),
"the contract must say an outgoing edge is refused"
);
assert!(
c.notes.iter().any(|n| n.contains("spawn -> void")),
"the contract must document the ungathered-ticket spelling"
);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions src/fan_out_contract_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ fn kinds_that_cannot_map_do_not_advertise_them() {
"merge",
"transform",
"code",
"void",
] {
let c = contract_for(kind).expect("contract");
assert!(
Expand Down
17 changes: 17 additions & 0 deletions src/model/node_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ pub enum NodeKind {
/// and leave the stragglers, or settle for whatever arrived before its
/// deadline. See [`crate::nodes::release`].
Gate,
/// Terminal sink: accepts items on `main`, discards them, and activates no
/// successors. The branch ends here, on purpose.
///
/// Purely declarative. A branch could always dead-end — a node with no
/// outgoing edges is lowered straight to the engine's `END` sentinel — but
/// an unwired port reads exactly like a forgotten one, so there was no way
/// to *say* "this is a side effect and nothing waits on it". This kind is
/// that sentence. It adds no concurrency: the work upstream of it still
/// runs inline in its own super-step, and only the result is dropped.
///
/// Abandon semantics, identical to an ungathered [`NodeKind::Spawn`]
/// ticket — nothing is drained or cancelled at run end. `spawn` → `void` is
/// the explicit spelling of a ticket no [`NodeKind::Gate`] will collect.
///
/// [`crate::validate`] refuses a `void` that has any outgoing edge, or that
/// has no incoming edge at all.
Void,
}

/// How a [`NodeKind::Trigger`] node is fired.
Expand Down
1 change: 1 addition & 0 deletions src/model/node_kind_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ fn node_kind_variants_use_snake_case() {
assert_wire(&NodeKind::SubWorkflow, "sub_workflow");
assert_wire(&NodeKind::Memory, "memory");
assert_wire(&NodeKind::Dedup, "dedup");
assert_wire(&NodeKind::Void, "void");
}

#[test]
Expand Down
10 changes: 6 additions & 4 deletions src/nodes/control_flow/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Native control-flow node executors: `condition`, `switch`, `merge`,
//! `split_out`, `transform`, `loop`, and `dedup`. Most of these are pure — they
//! route and reshape data within the engine and use no host capability. `dedup`
//! is the one exception: it filters items against durable `StateStore` state
//! (see [`dedup`] for why it lives here anyway).
//! `split_out`, `transform`, `loop`, `void`, and `dedup`. Most of these are
//! pure — they route and reshape data within the engine and use no host
//! capability. `dedup` is the one exception: it filters items against durable
//! `StateStore` state (see [`dedup`] for why it lives here anyway).
//!
//! One module per node kind so parallel work can edit them without conflicts.

Expand All @@ -15,6 +15,7 @@ pub mod scatter;
pub mod split_out;
pub mod switch;
pub mod transform;
pub mod void;

pub use condition::ConditionNode;
pub use dedup::DedupNode;
Expand All @@ -25,3 +26,4 @@ pub use scatter::{MAX_LANES, ScatterNode};
pub use split_out::SplitOutNode;
pub use switch::SwitchNode;
pub use transform::TransformNode;
pub use void::VoidNode;
102 changes: 102 additions & 0 deletions src/nodes/control_flow/void.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! `void` — the terminal sink.
//!
//! A `void` node accepts items on `main`, discards them, and activates nothing.
//! It is the end of its branch, and that is the whole feature.
//!
//! # Why a node for something the graph could already do
//!
//! A branch could always dead-end: [`crate::engine`] lowers any node with no
//! outgoing edges straight to the state-graph's `END` sentinel, and `END` is
//! filtered out of routing, so it contributes nothing to the next super-step's
//! active set. Wiring nothing to a port has exactly the same effect.
//!
//! What was missing is the *statement*. A port left unwired reads identically
//! to a port someone forgot to wire, so an author could not declare "this
//! branch is a side effect; nothing downstream waits on it", and a reviewer
//! could not tell intent from an accident. A `void` says it in the graph, where
//! both the reader and [`crate::validate`] can see it.
//!
//! One place that ambiguity was resolved *against* the author: a branch inside
//! a [`scatter`](crate::model::NodeKind::Scatter) lane that dead-ends is a hard
//! validation error, because a lane activation never writes the node's
//! top-level slot and so a stranded lane branch produces a wrong answer rather
//! than a failure. `void` makes that invisibility the contract instead of the
//! accident, and is therefore the one dead end a lane may have.
//!
//! # What it is not
//!
//! It adds **no concurrency**. Everything upstream of a `void` still runs
//! inline in its own super-step; only the *result* is dropped. If you want work
//! to overlap, that is [`spawn`](crate::model::NodeKind::Spawn) and the
//! `TaskRunner` capability, not this.
//!
//! It performs **no drain and no cancellation** at run end. Abandoning a branch
//! here is exactly what an ungathered `spawn` ticket already does, which makes
//! `spawn` → `void` the explicit spelling of "no [`gate`] will ever collect
//! this, and I meant that".
//!
//! [`gate`]: crate::model::NodeKind::Gate
//!
//! # Configuration
//!
//! None. A `void` node's `name` is where the human reason goes ("Fire and
//! forget: audit log") — it is already required, and unlike a config key it is
//! rendered by [`crate::visualization`]. Config is ignored entirely, including
//! `={{ … }}` expressions, so this node can emit no binding diagnostics.
//!
//! # What it leaves behind
//!
//! `{ "items": [], "port": null, "discarded": <n> }` in its run-state slot.
//! Emitting nothing would otherwise be indistinguishable from never having run,
//! since a node that never ran has no slot at all — so the three cases stay
//! separable:
//!
//! | slot | meaning |
//! |---|---|
//! | absent (`null`) | never activated |
//! | `discarded: 0` | activated, had nothing to drop |
//! | `discarded: 3` | activated, dropped three items |
//!
//! `discarded` counts **this activation's** input, not a running total. Two
//! consequences worth knowing: inside a `loop` body it is overwritten every
//! iteration, so the value that survives is the last one; and inside a scatter
//! lane it lands at `nodes.<id>.lanes.<lane>.discarded` rather than at the top
//! level, because a lane activation deliberately never writes the top-level
//! slot. A cumulative counter was considered and rejected for exactly those two
//! reasons — it would silently mean something different in each context.

use async_trait::async_trait;
use serde_json::json;

use crate::error::Result;
use crate::nodes::{NodeContext, NodeExecutor, NodeOutput};

/// Stable `tracing` grep prefix for every log line this node emits.
const LOG_PREFIX: &str = "[void]";

/// Terminal sink: discards its input and activates no successors.
///
/// See the [module docs](self) for why an explicit node beats an unwired port.
#[derive(Debug, Default, Clone)]
pub struct VoidNode;

#[async_trait]
impl NodeExecutor for VoidNode {
async fn execute(&self, ctx: NodeContext<'_>) -> Result<NodeOutput> {
let discarded = ctx.input.len();
tracing::debug!(
node = %ctx.node.id,
discarded,
"{LOG_PREFIX} discarding items; nothing downstream runs"
);
// No port: emitting on one would imply a successor could match it, and
// `validate` guarantees there is none. The `discarded` count is the only
// trace the node leaves, and it is what separates "ran on nothing" from
// "never ran" (see the module docs).
Ok(NodeOutput::empty().with_meta(json!({ "discarded": discarded })))
}
}

#[cfg(test)]
#[path = "void_tests.rs"]
mod tests;
Loading