The day after the event-sourced session model landed, a real requirement showed up: the factory needed to sequence a multi-task build (a real product, not a single freeform work session), and nothing in the Hekton estate did that yet. Terraform and blog “workflows” were prose runbooks with no dependency field. Task packets modeled one independent task each. There was a dependency-aware, multi-task orchestration gap, and Control Tower already had the one piece that gap actually needed: a real, tested, legal-transition-enforced state machine, one day old.
The fork that didn’t happen
The obvious move was a second system, purpose-built for DAGs: its own storage, its own state model, its own CLI surface. It would have been easy to justify: a task-DAG node genuinely isn’t the same thing as a freeform work session, on the surface. What shipped instead was an extension. A session became, optionally, one node in a task DAG: session_start gained additive fields (wave, depends_on, task_type, repo, task_spec, verify_command, gateway_run) that a plain, non-DAG session simply never sets. One new event type, session_dispatch, records that a task was actually sent to the dispatch engine for real execution; it’s a self-transition (active → active), not a new status, because a status alone can’t distinguish “ready, never dispatched” from “dispatched, waiting on its result”; that distinction needed its own event, not a new state value bolted onto the existing five.
Six states, computed, not stored
A new pure module derives six per-task DAG states from a dict of already-replayed session views; nothing new is stored, the same discipline as the session-status derivation the day before: waiting_on_deps, ready, in_flight, needs_review, blocked, closed. Two new modules do the surrounding work without touching that purity: dag_spec.py loads and validates a TOML DAG definition (unique ids, no unknown or cyclic dependencies) and orchestrator.py, the one genuinely new I/O-heavy module, ties the pieces together and shells out to the dispatch engine for the actual work. The orchestrator is synchronous by design: it runs one dispatch to completion before returning, so there’s no ledger-polling machinery to build or get wrong.
The guard that didn’t trust the exit code
The dispatch seam consumes a narrow, documented JSON contract from whatever runner it calls: an exit code, and specific stdout fields including status. The guard is deliberately strict: only in-progress or done counts as “the engine actually ran.” Anything else (a manual-handoff placeholder, skipped: true, a missing field, an unrecognized value) is refused before a session_dispatch event is ever appended, leaving the task ready for re-dispatch rather than falsely marked done. That guard didn’t get built speculatively; it closed a real risk the same session it was written (the subject of a later post in this arc).
What next
Real DAG state, including VeilGremlin’s own 12-task build plan, registered the same day, meant a real dispatch guard was now load-bearing, not theoretical. It got tested for real within days, by a refactor in a completely different lab that nobody was watching for.
What Next
When Exit 0 Lies
A dispatcher that reported success without doing anything, and why a refactor in a completely different lab is exactly the kind of change that should trigger your own review.