Decision Freshness: Scheduled State Re-evaluation in Smeldr v1.47.0

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.

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.