Ash Framework

AshConf 2026

When TimeMeets State

Time

NOUN  ·  /tʌɪm/

1. the indefinite continued progress of existence and events in the past, present, and future regarded as a whole.

2. a point of time as measured in hours and minutes past midnight or noon.

Oxford Dictionary of English

Conor searching the Oxford Dictionary and raising a finger

Physics, asked three times

Nobody Else
Can Define It Either.

Quantum mechanics cannot measure time. Position is an operator. Time is a parameter handed in from outside, and Pauli proved no self-adjoint time operator is conjugate to an energy bounded below.

Galapon 1999 · ashconf26.sinclair.software/resources/pauli

QR code linking to Galapon on Pauli's theorem

Quantum gravity's central equation has no time in it. The Wheeler–DeWitt equation reads ĤΨ = 0. Isham reviewed every scheme for getting time back and endorsed none of them.

Isham 1992 · ashconf26.sinclair.software/resources/wheeler-dewitt

QR code linking to Isham on canonical quantum gravity and the problem of time

One answer is to give up on it. Rovelli: "the best strategy for understanding quantum gravity is to build a picture of the physical world where the notion of time plays no role."

Rovelli 2009 · ashconf26.sinclair.software/resources/rovelli

QR code linking to Rovelli's essay Forget time

Time — the missing dimension

Time.

Most business processes are missing a dimension.

Ash Workflow reveal

Ash
Workflow

State learns to wait.

Demo
Time

OnlySands

Premium content. Coarse material.

OnlySands · The product

Desk
Sand.

Tiny escapes. Bigger ideas.

OnlySands · Research & Development

Vertically
Integrated.

From plant pot to production in one carefully governed supply chain.

A tiny trowel moving sand from a plant pot into a toy bucket

OnlySands · Global fulfilment

Sand.
Distributed.

Seven regions. Eight grain profiles. Absolutely normal packaging.

OnlySands is hiring

Chief
Sediment
Architect

Competitive base · equity · annual sand allowance

Production Ash experience
Distributed systems
Granular data
Eventual consistency
Get your hands dirty

OnlySands · Janine — HR Manager

Janine.

First gate · HR Manager · “Errrm… I don’t think so.”

Janine scribbling on a clipboard

The Disclosure and Barring Service

The
Bureau.

DBS is the UK agency that runs criminal record checks. It rules on a developer’s past indiscretions from its own screen, in its own time, up to twenty seconds.

The bureau inspecting a record through a magnifying glass

OnlySands · Steve — Engineering Lead

Steve.

Engineering Lead. Definitely not the Big Boss.

Steve shrugging

OnlySands · Conor — The Big Boss

The
Big
Boss.

Final approval. Obviously.

Conor drinking coffee

OnlySands · Run the demo

Run it
yourself

github.com/team-alembic/ash_workflow/tree/main/demos/ats

QR code linking to the ATS demo README

The demo, end to end

The Hiring Line

HR screen DBS check Lead interview Final approval Hired Rejected score ≥ 4 score ≥ 4 score ≥ 4 offer score < 4 score < 4 score < 4 veto

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

Write code as if failure doesn’t exist

A durable execution engine. Your function sleeps for thirty days and wakes on the next line.

Source: temporal.io

temporal.io
Temporal homepage

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

Jobs In
Postgres.

  • A job is a row.
  • Insert it in your transaction.
  • A worker picks it up and runs it.

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

Ash Workflow

AshStateMachine+AshOban

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

Policies
Still Apply

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
☰ 20 Calendar Today ‹› October 2026 ⌕?⚙ Month ⠿ C
MONTUEWEDTHUFRISATSUN
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12START
13
14REMIND
15
16
17
18
19ESCALATE
20
21
22
23
24
25
26
27
28
29
30
31
1

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

Why Not Schedule
The Exact Instant?

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

60 Seconds
Versus
Milliseconds

Cron runs a query on a schedule. It never learns the deadline.

How Oban keeps time

A Process That Wakes Itself

send_after(self(), :work, 60_000) Oban.Cron mailbox candidates one query auto_reject 9f2 auto_reject a71 auto_reject c04

The timer never learns a deadline. It only decides how often the question gets asked.

How a sequencer keeps time

Two
Clocks.

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

State_Entered_At

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

The Anchor
Keeps Moving

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

The Breach Never Arrives

0246810121416 days every :nudge timeout :breach state_entered_at now

Every nudge moves the anchor. The seven-day breach moves with it.

Transition log

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

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

Temporal
Resources

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

Rewind
The Board

Return to the exact instant the audience applied.

Can we drop the column?

Drop The
Column?

So…

Temporal lower bounds

Unrelated Writes
Reset The Clock

Row-version time is not workflow-state time.

Log versus temporal

Two Histories

Event logHow did it get here?
Temporal rowsWhat did it look like?

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

Model
Time.

mix igniter.install 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.