Smeldr's TransitionTrigger system lets you attach side effects to a state-flow transition, the built-in schedule-eval type, for example, reads a timestamp off an item and queues a future re-evaluation. Smeldr's own Decision type has used one since mid-July: ratify a Decision, and schedule-eval is supposed to queue its freshness check.
It never ran. Not once, for any Decision, since the feature shipped.
Two systems, one dispatcher
Smeldr has two different ways content changes status. Runtime-defined "dynamic" content types go through DynamicTypeRepo, a generic path that works off a type name string. Smeldr's own built-in types, Signal, Task, Decision, Amendment, Goal, Run, are "typed" modules, compiled Go structs with their own dedicated methods: updateHandler, MCPPublish, MCPSchedule, MCPArchive, and a background scheduler, processScheduled.
The function that actually fires a registered trigger, fireAsyncTriggers, had exactly two callers. Both were in the dynamic path. The five typed-module call sites, the ones Decision actually uses, never called it at all.
Why nobody noticed
Every one of those five call sites does real, working things: it validates the transition, saves the item, dispatches the ordinary lifecycle signals (AfterPublish and friends). A ratified Decision looks ratified. The UI shows it correctly. The audit trail records it. Nothing *visibly* breaks.
The one thing that doesn't happen is a background write to a queue table nobody's watching directly. That's a genuinely quiet failure mode: nothing throws, nothing logs an error, the absence is just an absence.
The fix, and why it's five lines, not five files
Once you find the gap, closing it is almost anticlimactic: one call to the already-existing fireAsyncTriggers, added right after each site's own Save call, matching exactly how the dynamic-content path already does it. No new function. No new exported API.
The interesting design question wasn't the fix. It was MCPUpdate, a sixth method that also changes content. Should it get the same call? No: MCPUpdate already, deliberately, restores an item's status to whatever it was before applying any update, its own doc comment says so. Inside that method, "before" and "after" status are always the same value. There is no transition to trigger anything from. Confirming that took reading one line of existing code, not writing one.
What testing this correctly actually required
The obvious test, call fireAsyncTriggers directly and check it fires, would have proven nothing new. That function already worked; the bug was that nothing typed ever *called* it. Every test for this fix drives a real updateHandler HTTP request, or a real MCPPublish/MCPSchedule/ MCPArchive/processScheduled call, and checks the trigger fired as a side effect of that.
One test needed a second layer of care. updateHandler's new call is guarded, skip it when status doesn't actually change, to avoid a wasted query on every ordinary edit. The three MCP methods aren't guarded at all. Proving that guard is real, and not just "nothing happened to match anyway," meant registering a trigger on the *exact* transition being skipped (a same-status self-loop) rather than a different one, otherwise the test would pass whether or not the guard code even existed.
What this doesn't fix yet
Fixing the trigger means a queue table starts filling up. Nothing drains it in the reference application yet: that's a second, separate gap, deliberately not shipped alongside this one. Automating a governance record's state change with no audit trail is a worse failure mode than a silently-empty queue ever was. That queue will now visibly, harmlessly accumulate until the observability gap closes too.