Smeldr v1.47.0 ships TransitionTrigger and App.DrainEvalQueue — a lightweight mechanism for scheduling automatic state transitions in the future, with no cron-at-definition-time complexity.
The problem
Governance workflows need time-based state cycling. A Decision ratified today should automatically surface for re-evaluation in six months. Previously, there was no way to express "when this item transitions to state X, schedule a follow-up transition at time Y" — you had to poll the database yourself.
What shipped
TransitionTrigger is a new struct on StateFlow:
err := app.RegisterFlow(smeldr.StateFlow{
Name: "governance-decision",
TypeName: "Decision",
// ... states and transitions ...
Triggers: []smeldr.TransitionTrigger{
{
FromState: "proposed",
ToState: "ratified",
TriggerClass: "async",
TriggerType: "schedule-eval",
Config: `{"eval_field":"next_eval_at","to_state":"pending-re-evaluation"}`,
},
},
})
When a Decision transitions proposed → ratified, Smeldr reads the next_eval_at field from the Decision row and inserts a queued entry into smeldr_eval_queue. The queue persists across restarts.
App.DrainEvalQueue processes due entries:
triggered, skipped, err := app.DrainEvalQueue(ctx)
Or wire it automatically:
sch, err := agent.NewEvalQueueScheduler("", "UTC", app) // runs every 5 minutes
sch.Start()
defer sch.Stop()
Design decisions
Fail-open everywhere. If next_eval_at is null or empty, the trigger silently skips — no errors, no blocked transitions. The eval queue is best-effort; a missed drain doesn't corrupt state.
Direct SQL UPDATE, not SetStatus. DrainEvalQueue writes directly to the item's table rather than going through the full SetStatus path (which would re-fire triggers, causing infinite loops). The trade-off: signal hooks and conflict checks don't run on drain. This is intentional for the re-evaluation use case.
Inline interface in the agent module. NewEvalQueueScheduler accepts an interface { DrainEvalQueue(ctx) (int, int, error) } rather than *smeldr.App, avoiding a circular import and keeping the agent module self-contained.
The governance cycle
orchDecisionFlow (the built-in Decision orchestration type) is now wired with two triggers — one for proposed → ratified and one for pending-re-evaluation → ratified. Every time a Decision is ratified, a re-evaluation is scheduled. Every time it's re-evaluated and re-ratified, the cycle restarts.
This means a live governance system never silently accumulates stale decisions.