AshStateMachine — every station, declared
state_machine do
initial_states [:applied]
default_initial_state :applied
transitions do
transition :screen, from: :applied, to: :screening
transition :interview, from: :screening, to: :interview
transition :hire, from: :interview, to: :hired
transition :reject, from: [:screening, :interview], to: :rejected
end
end
A transition the block does not list is refused. Transitions say what may happen. Not one of them says when.
Temporal.io
A durable execution engine. Your function sleeps for thirty days and wakes on the next line.
Temporal TypeScript
export async function candidate(id: string) {
await sendToRecruiter(id);
await sleep('30 days');
if (await stillUndecided(id)) {
await nudgeRecruiter(id);
await sleep('7 days');
await autoReject(id);
}
}
Oban
AshOban — the timeout lives elsewhere
oban do
triggers do
trigger :auto_reject do
action :reject
where expr(state == :review and
updated_at < ago(30, :second))
scheduler_cron "* * * * *"
end
end
end
Ash Workflow
The whole workflow
workflow do
step :hr_screen do
timeout :janine_responds do
fire_after {7, :seconds}
transition_to :hr_decision
end
end
step :hr_decision do
action :record_hr_screen
on_success :request_dbs, when: expr(score >= 4)
on_error :rejected
end
step :request_dbs do
action :call_the_bureau
on_success :background_check
on_error :rejected
end
step :background_check do
transition :dbs_clear, to: :final_approval
transition :dbs_flag, to: :final_approval
every :chase_the_bureau do
interval {20, :seconds}
action :chase_dbs
end
end
step :final_approval do
transition :offer, to: :hired
transition :veto, to: :rejected
timeout :jefe_no_answer do
fire_after {45, :seconds}
transition_to :rejected
end
end
step :hired
step :rejected
end
A step someone has to move
step :review do
transition :hire, to: :hired
transition :reject, to: :rejected
timeout :auto_reject do
fire_after {30, :seconds}
transition_to :auto_rejected
end
end
A step that moves itself
step :verifying do
action :run_verification
on_success :review, when: expr(score > 4)
on_success :rejected
on_error :verification_failed
end
Transitions are just Ash actions.
Only the big boss
step :review do
policy actor_attribute_equals(:role, :recruiter)
transition :hire, to: :hired
transition :reject, to: :rejected
end
Two deadlines, one step
timeout :reminder do
fire_after {2, :days}
action :send_review_reminder
end
timeout :escalation do
fire_after {7, :days}
transition_to :escalated_review
end
How AshOban finds work
trigger :auto_reject do
where expr(state == :review and
state_entered_at <= ago(30, :second))
scheduler_cron "* * * * *" # <- the ceiling
end
The obvious question
A scheduled job puts the deadline in two places: the record and the queue. Leave review and you cancel it. Move the deadline and you reschedule it.
The cron query asks the record, so there is no future job to keep in sync.
Cron versus a BEAM timer
Cron runs a query on a schedule. It never learns the deadline.
How Oban keeps time
The timer never learns a deadline. It only decides how often the question gets asked.
How a sequencer keeps time
A sequencer never plays a note from the loop that wakes it. The loop looks ahead and hands each note in the window to the audio clock.
Precise splits the same two clocks. The sweep reads every deadline inside horizon_ms and arms a BEAM timer for each exact instant.
Lookahead — arm the timer, don’t poll
workflow do
scheduler AshWorkflow.Scheduler.Precise
end
Naive timestamp
Simple. Indexable. Easy to schedule against.
One indexed column
attribute :state_entered_at, :utc_datetime_usec
where expr(state == :review and
state_entered_at <= ago(30, :second))
Recurring actions
Reminders obscure when the state actually began.
Running an action every interval
every :reminder do
interval {2, :days}
action :send_review_reminder
end
Put a deadline next to it
step :review do
every :nudge do
interval {2, :days}
action :send_review_reminder
end
timeout :breach, fire_after: {7, :days},
transition_to: :escalated
end
Both measure from the same column. If the nudge moved it, the breach would be seven days away again every two days, and never arrive.
Nine days in review, nudged every two
Every nudge moves the anchor. The seven-day breach moves with it.
Transition log
from · to · action · actor · time
Opt in — a resource you own
workflow do
transition_log MyApp.CandidateTransition
end
The generator writes it for you
mix ash_workflow.gen.transition_log MyApp.Candidate
Reading the history back
Candidate.history(candidate)
#=> [%CandidateTransition{...}, ...]
Candidate.state_at(candidate, ~U[2026-08-26 14:02:00Z])
#=> :review
Undo
Bounded. Actor-aware. Recorded as another event.
A window in which to change your mind
undo do
within {30, :minutes}
same_actor? true
end
step :review do
transition :hire, to: :hired, undoable?: true
end
Validity periods. Historical rows. “As of” reads.
Experimental · needs Postgres 19
temporal do
strategy :context
attribute :valid_at
end
Ash.get!(Candidate, id,
as_of: ~U[2026-08-26 14:02:00Z])
QR callback
Return to the exact instant the audience applied.
Can we drop the column?
So…
Temporal lower bounds
Row-version time is not workflow-state time.
Log versus temporal
Values versus events
Candidate
|> Ash.Query.filter(state == :review)
|> Ash.Query.as_of(~U[2026-07-31 23:59:59Z])
|> Ash.count!()
The same count, from the log
calculations do
calculate :state_at, :atom,
expr(first(transitions,
field: :to_state,
query: [
filter: occurred_at <= ^arg(:at),
sort: [occurred_at: :desc]
]
)) do
argument :at, :utc_datetime_usec, allow_nil?: false
end
end
Candidate
|> Ash.Query.filter(state_at(at: ^~U[2026-07-31 23:59:59Z]) == :review)
|> Ash.count!()
Thank you
mix igniter.install ash_workflow
team-alembic/ash_workflow
hexdocs.pm/ash_workflow
Today I want to talk about the dimension that is missing from how we model the real world. I want to talk about time. #### But who am I I'm lead engineer at Alembic, the expert Ash and Elixir consultancy, based in Australia. We have an amazing team of engineers all around the world Away from the keyboard, I'm a father. I have a three-year-old boy, and I live with my family in Spain. I spend my summers teaching people how to weave willow into flower crowns at festivals across the UK. But none of that will be appearing in this talk. no - willow weaving - LLMs - coding agents
But what is time? It's actually a really hard thing to define. ... That comes close.
Ask a physicist - lengthy answer - "we don't really know." conflicting ideas, left out of a lot of different models.
Time is an intrinsic part of the world It's tied into everything at a very deep level. But when we're modelling systems and processes we treat it as an afterthought. Typically we're far more interested in the event itself more so than when it happened.
Throughout this talk I'll be diving into a new library I'm building, called Ash Workflow, which aims to answer that disparity.
Before we dive into the library itself, I'm going to take a slightly unconventional turn and go straight into a demo. For this I'm going to be inviting all attendees to apply for a job at the company I'm forming.
It's called Only Sands. We ship small, confectionery-sized bags of sand all over the world. You might be thinking, "Conor, why would I need a very small bag of sand?" For which I would answer: there are a ton of different uses.
Having a nice little island escape on your desk can be a huge boost to your mental health, clarity and focus.
We're vertically integrated. Agents on the ground source the very best sediment, and our own couriers carry it to the customer's door.
Seven regions, eight grain profiles. Getting a bag of sand through customs is harder than you might imagine.
Which is why we're hiring a Chief Sediment Architect. You'll need production Ash experience, distributed systems, granular data, eventual consistency, and a willingness to get your hands dirty. In return, you'll get a competitive base, equity, and an annual sand allowance.
But to get the job, you're going to need to get past our HR manager, Janine. She's the first gate. She's going to screen you and decide whether you're worth the technical team's time. She's a complete machine. Very methodical. She guarantees me an answer within seven seconds.
The Disclosure and Barring Service checks applicants' criminal records. when you're moving sediment like this across seven continents, we need applicants to be squeaky clean. The bureau is a fake third-party API we call out to. It goes looking for your past indiscretions and gives us a heads-up as you move through the process. It answers in its own time, up to twenty seconds like most government APIs, it fails about half the time.
With the background check result in hand, you speak to Steve, our engineering lead. That is, if he's got time.
And with all of that approved, you come through to me. The Big Boss. I make the final call. Though if I leave it long enough, the decision gets made for me.
So if you wouldn't mind scanning this QR code. It'll take you to an application form. Give me your name and a reason you should be Chief Sediment Architect at OnlySands.
Here's what you just watched, as a line on a map. HR screen, then the DBS check, then the lead interview, then final approval. Hired at the end of the line, and rejected as a possibility at any point. In Ash applications we're very comfortable modelling this. There's a finite number of states, and known transitions between them.
We have AshStateMachine, which gives us a really nice DSL for saying which state you start in and how you can get from one state to the next. Ash enforces it, so you can only move the way the block says. Very clean, very effective. But it tells us nothing at all about time. Transitions say what may happen. Not one of them says when. What happens if something doesn't happen within a given time? We're missing an entire dimension. This is purely the state. What we need is a workflow: a set of steps that come together across time.
There's already a SaaS product built around this idea, and it's been around for years. It's called Temporal. Temporal is a durable execution engine. It lets you write a long running process as one function, even over days or weeks. This take infrastructure tho, Temporal runs as a separate service, workers poll it for work, and your workflow code has to be safe to replay. For years, before I found Elixir, I really wanted this capability.
Sorry I should have given a trigger warning. It's JavaScript. this is code you might write in Temporal . But I think what it expresses is something really quite powerful You can see some request going off to the recruiter. Then it sleeps for thirty days and comes back to check. If we still haven't decided, give them a nudge, another seven days, then auto-reject. It's not particularly pretty. But I think it is powerful, and a really interesting idea: bringing real-world time into the business logic.
So how do we model time in Elixir applications? In Elixir we have Oban. Oban is a background job queue that keeps its jobs in your own database. A job is a row, inserting it is part of your transaction, and a worker picks it up and runs it. It's great at firing off work that happens in isolation at a given time. You can schedule a job for a moment, or you can say that when certain criteria are met, go and do some work. And that runs in its own separate process.
AshOban improves this by letting you declare the job itself on the resource instead of in a worker. Here's an example of how you'd define one of those triggers by hand. The `scheduler_cron` tells us every minute to come along and run a check over that `where` clause. So in this instance, we look for anything with `state` in `:review`, and having been updated more than thirty seconds ago. Any record which match that `where` have the `:reject` action triggered. That's powerful, for sure. It isn't ergonomic. It's quite difficult to reason about exactly when this trigger will fire, and the timeout lives nowhere near the state it governs. But it does mean we have the other half of what we need: time going forwards, and transitions between known states.
That's all Ash Workflow is. An ergonomic wrapper around a state machine and Oban. States and transitions on one side. Scheduled work on the other.
So what does a workflow look like? You start with a `workflow` block, and inside it a set of known steps the workflow can be in. Then the time dimension can be added on top, which I'll go into in more depth in a second. Here are some of the steps we saw in the demo. The `:hr_screen` right at the top there, will be the first step the workflow starts in when this resource is created. Then there are transitions which can be triggered at various steps in the workflow, and some actions being called with their results determining what step should occur next.
Lets look a bit closer at some of these steps. Here we have an example of a step which will require someone, or something outside the workflow, to move. Once we reach this review step, somebody has to come and transition it. They say `hire` or `reject`, and that moves the record to the next known state. But there's another block here, a timeout, which says that if thirty seconds pass and we haven't left this state, transition them to `auto_rejected`.
Another step here is showcasing how an arbitrary action can be wrapped in a step, with its result dictating what will happen next. The moment you enter the step, the action fires, `run_verification` in this case. Then, depending on the result, you say where success goes and where an error goes. As you can see `on_success` can also be conditional: and route to a step based on the action's result.
These are all just Ash actions. And since transitioning between steps is an Ash action, you can attach policies to them.
On this review step, only an actor with the role of recruiter can transition to hired or rejected. And you can put arbitrary rules in there.
You can also have more than one deadline on a single step. Say you came into the review state on Monday the twelfth. You could be reminded two days later, and then a week after that escalated into some other state.
That's expressible with two timeout blocks. After two days, trigger that action. After seven days, transition to an entirely different state.
So how does this actually run? AshOban checks for records still in `review` whose thirty-second deadline has passed. On each cron tick, it finds the matches and queues the work. That means the deadline is a condition on the record, not a job we insert when the record enters `review`. An astute eye may spot that Cron only checks once a minute, so that means we actually can't express anything finer grain, like 30 seconds say, as the exact instant of firing will be at most 30 seconds later
Why not schedule the exact instant? We could. Oban can store a job to be run at an exact point in time. But now the deadline lives in two places: on the workflow record and in the job queue. If the record leaves `review`, we need to cancel its job. If its deadline moves, we need to reschedule it. And if the job has already started, cancellation is too late—the worker must check whether the timeout still applies before doing anything. With the cron query, the current record decides what’s due. There’s no future job to keep in sync.
Lets take a slight detour, so that the demo runs faster than one transition a minute. Since Cron has at most a single minute precision
First, how Oban works internally. It's a GenServer on a loop, sending itself a message and then checking the database for work. It asks what fits this where clause, and anything that matches gets spun off into its own job and executed. We can do a similar thing. If we wanted millisecond precision we could call a GenServer every millisecond looking for work. But there's always going to be lag.
The solution comes from audio programming: run two clocks. There's the current time, and then whenever the GenServer looks for work it looks over a horizon. Over the next ten milliseconds, or a full second, which jobs are about to match that where clause? Then you schedule those precisely, because you can tell the BEAM to run something at exactly this instant. That's lookahead. The reason you need it in audio is that you cannot block the main thread. To the human ear it's incredibly obvious the moment you're a few milliseconds out — things start sounding muddy. So if you're writing a sequencer, you keep the map of when the notes are supposed to hit, and you hand each one to the dedicated audio thread ahead of time, saying: at this instant, I want this to happen. It handles the precision. A sequencer never plays a note from the loop that wakes it.
So I ended up writing a new scheduler, and it's configurable. You say use the precise scheduler instead, and it holds the GenServer doing the lookahead.
So how do we actually look into the future? A very simple timestamp field called `state_entered_at`. Every time you transition, we stamp exactly when you entered. From there we can look ahead: we're seven days from then, so this one is about to fire.
One single indexed column to look up. Anything in the review state — did we enter it more than thirty seconds ago?
We have a slight problem when we get to recurring actions.
Here's the DSL: every two days, trigger this action. You can put it on any step, with any interval. The naive approach to having a feature like this is to just reset the `state_entered_at` field, which will work, that will prevent the same action from firing over and over, and signal that it needs to happen again the next interval.
If you put a timeout next to it, we have a bug. Both of these measure from the same column. If the nudge moved it, the breach would be seven days away again every two days, and never arrive.
Watch it happen. Nine days in review, nudged every two. Every nudge drags the anchor forward, and the breach line moves with it. The deadline is always seven days away, and it never arrives.
So far we've been looking into the future. What happens when we want to look into the past? The answer ends up being a log of transitions. Keeping track of what the state was, what its becoming, what ran action, who the actor was, and when. It keeps track of when you moved into a state, so you have the full history, and you get a history view of when things happened.
It's an additional resource that your application controls, which you reference from your workflow. You say the transition log is this module
It's a very basic resource, and you can generate one with the right shape with this mix task.
And it gives you a couple of nice conveniences. You can read the history of every transition, and you can ask what the state was at a specific time.
This also means a new feature drops out of it very simply: undo. You look at the log, take the last transition, and reverse it.
We can put that in the DSL. Certain transitions are marked undoable, so if I hire someone I can undo it immediately. And there's a block where you say only within the next thirty minutes, and only by the same actor. You can put additional conditions on there. > If there's time, demo the /timeline Let me show the log in action. We have this playhead, and we can see the state of every candidate as it happened. They started, Janine responded, I vetoed, and then I undid that veto. We can scroll back and see exactly what the state was at a given point, which is pretty handy.
Finding out what the state was at a given point is something a new Ash feature aims to tackle, and that's temporal resources. Zach talked about this in his talk at Goatmire.
It's exciting, it's new, and it's PostgreSQL only. You get an as-of read. You put a `temporal` block on the resource, say the strategy is context and the attribute is `valid_at`. Instead of one record you have many: each change is its own row in the database, and the only thing that tells you which one is correct is the date you're asking about. So depending on when you are — most likely right now — you can go and time travel, and pick up the row that was true then.
Either of those approaches lets us rewind the board and see exactly where we were at a given point.
So could we drop the column?
You have to be careful with temporal resources, because when other fields change it moves the bound you might have been anchoring to. Row-version time is not workflow-state time. You couldn't have `valid_at` drive the whole workflow if you needed specific times. You have to keep track of the state transition yourself.
That's all unreleased, and mostly of interest. But which should you go for in your own applications? Which is the right abstraction for dealing with time? Do you want an event log of everything that happened, or temporal rows where the database tells you exactly what the data looked like? The split ends up being: if you want to know how you got there, you want an event log. If you want to know what it looked like — what every field ended up being — you want temporal rows.
Here’s a question: how many candidates were in review at the end of July? With temporal resources, I can ask that directly: filter for `review`, look at the records as they were on that date, and count them.
Our transition log can answer it too. For each candidate, find the last transition before that date and count those that landed in `review`. The difference is what we’ve kept. The log records **workflow events**: when a candidate moved and why. Temporal resources preserve **past versions of the record**, so we can ask what its other fields looked like then, too.
So there we have it. This has been When Time Meets State, and I hope you found it interesting. It's been a lot of fun writing this library. It's on Hex, and the code is on GitHub, and both are on the slide with a QR code under each. The install command is there too, if you want to start now. Contributions are welcome. Use it, try to break it, have some fun with it. But for the most part, just try to think about time as an essential ingredient when you're modelling your application state. Thank you very much. I've been Conor Sinclair. See you around.