SherlockLiu Logo SherlockLiu
Back to all posts
Engineering

Waterfalls: The One Event Pattern That Runs Everything (Part 8)

SL
Aug 14, 2026 6 min read
Waterfalls: The One Event Pattern That Runs Everything (Part 8)

Series: Inside the DeepSeek Harness — Part 8 of 16


Every post in this series so far has quietly leaned on one mechanism without stopping to explain it: the waterfall. Part 4’s guard chain, Part 6’s invariant listener, Part 7’s compaction trigger — all of them are waterfall dispatches. This post finally opens that box. It’s a short one, because the mechanism itself is genuinely small — about ten lines of real code — and the interesting part is how much policy the whole system builds on top of those ten lines.


Five dispatch modes, one decision per event

Every extension point in dsh is exactly one of five Cordis dispatch modes, and the mode is part of the event’s public contract — a new event declares which one it uses.

mode awaited? order dsh example emit no, registration order session/event, agent/status parallel yes, all at once session/flush serial / bail yes / no, until bail agent/turn-stopping waterfall wraps next() agent/pre-step, llm/stream, tools/*, fs/*-intent

Figure: five modes, and the busiest, most policy-heavy one is waterfall — everywhere a single owning decision has to be made.

isBailed(value) decides when serial/bail iteration stops early: a value stops the chain unless it’s null, false, or undefined — any other return, including 0 or an empty string, counts as “bailed.” That’s the whole rule for those two modes. Waterfall is a different shape entirely, and it’s the one worth reading the actual code for.


The real implementation

This is close to the literal source, and it’s short enough to walk through line by line:

// vendor/cordis/src/events.ts (abridged, real)
waterfall(...args: any[]) {
  const cbs = this.dispatch('waterfall', args)
  const inner = args.pop()
  const next = () => {
    const cb = cbs.shift() ?? inner
    return cb(...args)
  }
  args.push(next)
  return next()
}
cbs = [listener1, listener2, listener3] next() call 1 cbs.shift() = listener1 listener1(...args, next) calls next() again to delegate each next() shifts one more off the front, until empty cbs empty → cb = inner (the default)

Figure: `cbs.shift() ?? inner` — the entire waterfall mechanism is one array being consumed front-to-back, falling through to the built-in default once it's empty.

There’s no separate “short-circuit” code path anywhere in this. cbs is the listener array, inner is the built-in default behavior. Each call to next() shifts one more listener off the front of cbs; once the array is empty, further calls invoke inner directly. A listener that doesn’t call next() simply never triggers another shift. The chain doesn’t detect a short-circuit and branch — it just stops advancing, because nothing asked it to advance further, and that listener’s return value propagates straight back up through however many next() calls are still on the stack. This is the mechanism behind every claim this series has made about waterfalls: “a policy listener can return without next() when it owns the decision” isn’t a rule enforced by a framework check — it’s what naturally happens when a function just doesn’t call another function.


Durable vs. live: the split that decides where new behavior goes

Every event in dsh sits in one of two planes, and picking the wrong one is the single most common category of bug this pattern is designed to prevent.

Durable — SessionEventMap turn/*, step/*, user/message, assistant/*, tool/*, request/header ~12 variants, appended, broadcast as session/event use when the fact must survive a reload Live — coordination events agent/*, tools/*, fs/*, llm/*, system-prompt/* never persisted, exist only during the dispatch call use to observe or intercept work in flight

Figure: the rule connecting the two planes is Part 6's invariant — new model-visible input needs a session event, not a live one.

The connecting rule is exactly Part 6’s invariant, restated from the event-taxonomy side: anything reaching a model request must be reconstructable from the log, so new model-visible input has to extend SessionEventMap, not ride on a live event that vanishes the moment its dispatch call returns.

Two numbers worth sitting with, from the repo’s own generated producer/consumer matrix (this doc is generated fresh from current master, not pinned to the series’ cited commit, so treat these as current rather than commit-frozen): agent/pre-step — a single waterfall — currently has 14 real listener registrations across compaction, plan mode, goal-round-driver, session-checkpoint-policy, hooks integrations, and more (tool-skill alone registers two separate listeners on it). session/event has 23 listening packages. Neither number is a metaphor for “lots of things can hook in” — they’re literal counts of independent plugins all wrapping the same dispatch point, which is a fairly direct rebuttal to any read of this architecture as simple: the primitive is small, but what gets built on top of it is not.


Scoped dispatch: the event knows whose activity it’s about

One more wrinkle connects straight back to Part 3’s scope primitive. An event about one specific agent’s activity dispatches carrying that agent’s scope as a filter — untagged listeners still receive it, plus listeners explicitly tagged with that scope or an ancestor of it. Events about a registry itself — “a tool was added,” rather than “agent A did something” — are deliberately registry-subject and stay unfiltered, because they’re not about any one agent’s activity in the first place. Getting this distinction backward — filtering an event that should be global, or leaving unfiltered an event that should respect scope — is exactly the kind of bug that would let one agent’s private tool override leak into another agent’s dispatch, which is why the codebase treats it as an explicit, checked invariant rather than a convention.


Compare: Claude Code — the sharpest contrast in this series

This is the comparison worth slowing down for. Claude Code’s hook system is a dedicated extensibility subsystem: 26 named lifecycle events across 6 categories (Tool, User, Session, Sub-agent, Compression, Permission), 5 distinct hook execution engines (Command, Prompt, Agent, HTTP, Function), a structured JSON response protocol layered on top of process exit codes, and a 3-layer security model gating which hooks a given deployment trusts. It is comprehensive, and it is bespoke — every one of those 26 events is its own named thing the team designed, documented, and secured separately.

dsh’s answer to “how does a plugin author hook into anything” is five generic dispatch modes — emit, parallel, serial, bail, waterfall — the same five, reused for every event in the system, including this post’s guards, Part 4’s permission pipeline, and Part 7’s compaction. There’s no separate hook-security model, because there’s no separate hook subsystem to secure — the same scope-and-restriction rules from Part 3 already govern who can register a listener at all.

Neither is obviously better as a piece of engineering. Claude Code’s 26 named events are more legible — an event named PreCompact tells you exactly when it fires, where a dsh agent/pre-step waterfall requires reading the emitting call site. But dsh’s bet pays a real dividend: a bug fix or a performance improvement to waterfall() improves every one of those consumers simultaneously, because they’re not 26 separate implementations, they’re 26 (or however many) call sites of the same five primitives. That’s the general-primitives-vs-purpose-built-machinery trade this whole series keeps circling back to, and this is where it shows up starkest.


Coming up in this series

(All 16 parts are live today — no daily drip.)

Part Title What it covers
1 DeepSeek Harness: Inside the Open-Source Claude Code Rival The launch, the comparison, no privileged core, the four Cordis primitives
2 Composing an App From YAML, Not Code Profiles, bundles, five patch layers, boot, live HMR, --dump-config
3 Scope: Why a Live Agent Is the Key of Its Own Registration Shadowing, restriction, lineage vs. scope
4 Tool Execution in DeepSeek Harness: Guards and Approval Pre-execute, monotonic guards, post-execute, approval
16 DeepSeek Harness: Agent Presets as Data, Not Code Agent presets, PTC/Code Mode, the plugin ecosystem, compared to Claude Code’s agent types
5 Capability Seams: Making Bash Swappable for Sandboxed Bash The 3-role pattern end-to-end, across ~85 real seams
6 The Session Log: DeepSeek Harness’s Enforced Invariant The real invariant-checking code, surface projection
7 Persistence and Compaction: Crash-Safe by Construction JSONL/SQLite, torn-tail repair, the compaction lock bracket
8 Waterfalls: The One Event Pattern That Runs Everything (this post) Five dispatch modes, durable vs. live events, retry
9 The Agent Loop: Turns, Steps, and a Real Cancellation Bug The phase state machine, the inbox, a dated bug fix
10 The LLM Layer: One Message Format, Every Surface Message vocabulary, streaming, retry-via-log-replay
11 Subagents and Workflows: Composing Agents From Agents Provider kinds, continuable children, Ralph rounds
12 Defense in Depth: Sandboxing and Four Real Incidents bwrap/Landlock/Seatbelt/ACL, real production postmortems
13 Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP Typert codegen, the four-quadrant RPC envelope
14 Engineering Rigor: DeepSeek Harness’s Verification Gate The 100% coverage gate, real engineering-culture quotes
15 What a Second Production Harness Teaches dsh vs. the Agent Harness series, side by side

Part 16 shipped after this series’ initial 15 posts, once Agent Presets and Code Mode landed — it reads best right after Part 4, which is why its row sits there instead of at the end.

Next: Part 9 puts the waterfall to work on the biggest stage in the whole system — the agent loop’s own turn/step machinery, including a real, dated bug about a wake message that could get dropped during cancellation.


References

Comments