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.
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()
}
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.
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
- Source citations pulled directly from the repo as of commit
47f9438(master, Aug 13, 2026 — same commit cited throughout this series):vendor/cordis/src/events.ts(waterfall(),isBailed,emit/parallel/serial/bail), pinned to the same vendored Cordis this whole series cites docs/event-producer-consumer.md— the generated, CI-verified producer/consumer matrix (real listener counts cited above)docs/architecture.mdanddocs/cordis-primer.md- Part 1 through Part 7 of this series
Have thoughts on this?
I read every email. If something resonated, felt wrong, or made you think — I'd love to hear from you.
Comments