This project has been succeeded by GraphReFly. New development happens at graphrefly-ts. npm install @graphrefly/graphrefly
Skip to content

March 24, 2026

Why Control Signals Don't Belong in the Data Stream

Arc 4, Post 11 — Architecture v3: The Type 3 Breakthrough


Reactive libraries need two different conversations at once: “something may change” (control) and “here is the next value” (data). If both use the same envelope, every operator becomes a classifier — is this payload or prelude? — and the simplest map is wrong twice.

callbag-recharge’s rule after v3 is blunt:

  1. Type 1 DATA carries only real values. No sentinels, no undefined as “I am dirty,” no parallel vocabulary hiding inside the value stream.
  2. Type 3 STATE carries DIRTY, RESOLVED, and unknown signals that must propagate downstream unless a node has a deliberate, documented reason to absorb them.
  3. DIRTY before DATA, always — phase one establishes pending; phase two delivers DATA or RESOLVED.

This is not purism. It is receiver ergonomics. A sink that only implements DATA + END can still attach to a store; a tier-2 wrapper that uses subscribe() and only observes values is not forced to understand graph coordination — yet tier-1 nodes still participate in diamond resolution because they see the full protocol.

Extensibility without version churn

Unknown STATE signals forward by default. That is how we avoid another “flag day” when we add PAUSE, RESUME, or lifecycle verbs: intermediates pass what they do not understand, and only nodes with explicit handling change behavior.

If those lived on DATA, every map/filter/scan would need a default branch for “not actually data,” or the ecosystem would fracture into wrapped value types.

Suppression is not silence

When a transform decides not to emit a new value (filter rejects, distinctUntilChanged sees equality), v3 does not “emit nothing.” Silence after a forwarded DIRTY leaves downstream bitmasks stuck. RESOLVED is the phase-two message that means: the pending wave is over; nothing new on the wire.

So control is not only “before values” — RESOLVED is part of the same vocabulary as DATA, but still on STATE, not masquerading as a value.

Tier boundaries stay honest

Tier-2 nodes (async timers, inner subscriptions) do not see upstream DIRTY/RESOLVED the same way tier-1 nodes do — they start fresh cycles via autoDirty and producer semantics. Separating DATA from STATE keeps that boundary visible in the spec: tier-1 speaks full protocol; tier-2 bridges imperatively at the edges without pretending async is the same shape as sync transforms.

Further reading


Next: RESOLVED and subtree skipping.

Released under the MIT License.