A Task moving from waiting-plan to plan-reviewing is, in this project's own workflow, the single most consequential event that can happen: it's the "the plan is ready, go read it" signal every session this month has depended on. Until now, that transition fired nothing. App.TransitionItemWithReason, the mechanism behind the MCP transition_item tool, called one internal trigger dispatcher and stopped. No webhook, no bus event, nothing a subscriber could act on. The only way to learn a Task moved was to ask.
Why the obvious fix was wrong
Smeldr already has a webhook system, and it already fires on every content lifecycle event: create, update, publish, archive. The tempting move is "just make transitions fire through the same pipe." It doesn't fit, for two separate reasons, both worth naming because either one alone would have been enough to reject it.
First: the existing pipe's vocabulary is closed. App.OnSignal listens for AfterCreate, AfterPublish, AfterArchive, and four siblings, a fixed set built around Draft/Published/Archived. A Task doesn't live in that world. Its states are backlog, active, waiting-plan, plan-reviewing, commit-reviewing, resolved, declared per state-flow, extensible at runtime via define_state_flow, with no relationship to "published" at all. There's no constant a waiting-plan → plan-reviewing move could ever map to, and inventing one per state per flow doesn't scale: new flows and new states get declared without a core release.
Second, and independent of the first: the existing pipe's payload builder needs an actual typed Go value to read a title and slug off of. TransitionItemWithReason never has one. It works entirely at the raw-SQL layer, look up whichever table the type name resolves to, read id/slug/status as plain columns, specifically so it can handle every registered type generically without importing every struct. There's no item to hand the old pipeline even if the vocabulary problem didn't exist.
What got built instead
A second, parallel delivery path, living beside the existing trigger dispatcher rather than inside the signal bus. It builds its own payload from the columns already in hand, names the event "{type}.transitioned", task.transitioned, decision.transitioned, and reuses the *delivery* half of the existing webhook machinery (the endpoint lookup, the job queue, the HMAC signing, the retry logic) by extracting the one piece of webhookDispatch that was already generic: look up subscribers for an event name, enqueue a job for each. Two payload-building paths now feed one delivery tail instead of two full copies of the same loop.
The other half of the task folded in a second silent gap: when an automated transition hits a role boundary it isn't allowed to cross, Smeldr already records a Signal naming the role that has to act (D42's own design: automation stops and says so, out loud, rather than either forcing the transition or failing invisibly). That Signal insert was raw SQL too, bypassing the normal create path the same way the transition itself did. It now fires signal.created, deliberately the *same* event name a human-created Signal already produces, so a webhook subscriber has no way to tell "a person made this" from "automation hit a wall and is asking for help," which is correct: a Signal is a Signal regardless of who wrote it.
What stayed out
Dynamic, runtime-defined content types have the identical gap on their own custom state flows, same missing dispatch, same root cause. They didn't get fixed here, because the object that would need to fire the event doesn't currently hold a reference to the webhook store or worker pool at all; wiring that through is real, separate work this task's own scope didn't ask for. Named in the plan rather than quietly left for someone to rediscover later.