Experimental
Temporal resources are experimental, and the API may change. In production they need
ash_postgres running on PostgreSQL 19+, which is still in beta itself. It's what gives
us SQL:2011 application-time period tables: PRIMARY KEY (... WITHOUT OVERLAPS), PERIOD
foreign keys, and UPDATE/DELETE ... FOR PORTION OF. This guide is written against
PostgreSQL for that reason. Ash.DataLayer.Ets supports temporal resources too, in memory.
Your standard resource only knows what's true right now. Update a row, and whatever it said
before is gone. A temporal resource keeps all of it. Every row is valid for a period, a
half-open range [from, to), and a single record is spread across many rows, one for each
period of its history.
Reading a temporal resource always happens at a point in time. You see the one version of each record that was valid at that instant, and nothing else.
You might see this called an application-time period table. The period is your time, set by your application, as opposed to system time, set by the database's clock.
Defining a temporal resource
Give your resource a temporal section naming its period attribute, which is an
Ash.Type.Range over a datetime, and use a data layer that supports it:
defmodule MyApp.Subscription do
use Ash.Resource,
domain: MyApp.Billing,
data_layer: AshPostgres.DataLayer
postgres do
table "subscription"
repo MyApp.Repo
end
temporal do
strategy :context
attribute :valid_at
end
attributes do
attribute :id, :integer, primary_key?: true, allow_nil?: false, public?: true
attribute :plan, :string, public?: true
attribute :valid_at, Ash.Type.Range,
allow_nil?: false,
constraints: [
inner_type: :utc_datetime_usec,
lower: [inclusive?: true],
upper: [inclusive?: false]
]
end
actions do
# `valid_at` is intentionally NOT in `accept` — see "Writing".
defaults [:read, :destroy, create: [:id, :plan]]
end
endYou don't actually have to declare the period attribute. Leave it out, and temporal declares
it for you, exactly as above:
temporal do
strategy :context
attribute :valid_at
end
attributes do
attribute :id, :integer, primary_key?: true, allow_nil?: false, public?: true
attribute :plan, :string, public?: true
endEither way, it's marked generated?. You never pass a period in as action input. Its value
comes from the instant of the write. If you declare it yourself, it can't allow nil, and it has
to keep the [from, to) bounds.
The migration generator handles the table for you. It emits the period column, a
PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) (a GiST exclusion that stops two rows of the same
id from overlapping in time), and installs btree_gist.
Reading "as of" a point in time
Use Ash.Query.as_of/2, or pass as_of to any builder or action:
# time travel: the subscription as it was on Jan 15
MyApp.Subscription
|> Ash.Query.filter(id == 1)
|> Ash.Query.as_of(~U[2026-01-15 00:00:00Z])
|> Ash.read!()
# `as_of` is accepted anywhere `tenant` is — opts, code interfaces, get, etc.
Ash.get!(MyApp.Subscription, 1, as_of: ~U[2026-01-15 00:00:00Z])Leave out as_of, and you're reading now. You get the current state, exactly one row per
id, and never the full history. There's no "all of history" read. Every read is a single
point in time.
as_of travels the same way tenant does, through the shared context. Loaded relationships,
calculations, aggregates and nested actions all pick it up, so everything you get back comes
from the same moment.
now() is anchored to as_of
Inside filters, calculations and validations, now(), ago() and from_now() mean the
query's as_of, not the wall clock. That's what keeps time travel consistent with itself.
Evaluate expr(activated_at < now()) as of last year, and it compares against last year.
now() is also worked out once for the whole operation, rather than again for every expression.
Writing
A temporal write makes something true from as_of onward. The new row is [as_of, ∞). A few
things follow from that:
- You never set
valid_atas action input. You passas_ofinstead, so leave the period attribute out of every action'saccept. Set the instant with theas_ofoption orAsh.Changeset.as_of/2. Pass neither, and a singlenowis pinned for the whole write, so the period, any&DateTime.utc_now/0defaults (likecreate_timestamp), and the stampedas_ofall share the exact same instant. - An update splits the period. Updating as of an instant cuts the currently valid version
off at that instant, and writes a new one with the new values from there to wherever the
old version ended. That's only
[as_of, ∞)when the version it split was open-ended itself. Split at the exact instant a version began, and there's nothing left before it, so the update works like an ordinary overwrite. - A destroy ends validity. It doesn't delete history. Destroying as of an instant cuts the
currently valid version down to
[lower, as_of). The record is gone from that instant on, and untouched before it. Destroy at the exact instant the version began, and it's removed entirely.
# create the current version, valid from now on
MyApp.Subscription
|> Ash.Changeset.for_create(:create, %{id: 1, plan: "bronze"})
|> Ash.create!()
# "as of" March 1, change the plan: history before March 1 is preserved
sub
|> Ash.Changeset.for_update(:change_plan, %{plan: "gold"}, as_of: ~U[2026-03-01 00:00:00Z])
|> Ash.update!()Pass as_of as the action option
Some changes need the instant while the changeset is being built: cascading destroys,
manage_relationship, and identity pre-checks and eager checks. For writes like those, pass
as_of as the action option (for_create(:create, input, as_of: ...)), not with
Ash.Changeset.as_of/2 afterwards. By then it's too late.
When no version is valid at that instant
An update or destroy acts on the version that's valid at as_of. If there isn't one, there's
nothing to split, and the write is refused as a stale record. That's true whether the last
version already ended, the only one hasn't started yet, or as_of falls in a gap between two.
To bring back a record whose history has run out, create it again rather than updating it.
The create opens a fresh [now, ∞) next to the closed history, which you can still read at its
own instants.
Scheduling a change ahead of now
A create always opens [as_of, ∞), so two creates always overlap. That means you can't create a
record now if a later version of it already exists. Only an update produces a bounded period,
because it inherits the end of the version it splits, so a change in the future is written as
an update:
# splits into [now, 2027-01-01) and [2027-01-01, ∞)
sub
|> Ash.Changeset.for_update(:change_plan, %{plan: "gold"}, as_of: ~U[2027-01-01 00:00:00Z])
|> Ash.update!()A future version that was created has to be unwound
If the later version was created rather than written as an update, it holds
[its instant, ∞), and nothing can be written before it. To get out of that, destroy that
version, create the present one, and then make the future change again as an update.
Relationships
A belongs_to can point at another temporal resource for the matching period. Declare
temporal_keys, and you get a Postgres PERIOD foreign key:
relationships do
belongs_to :tier, MyApp.Tier do
source_attribute :tier_id
destination_attribute :id
temporal_keys {:valid_at, :valid_at}
end
endPostgreSQL only supports NO ACTION on PERIOD foreign keys, so on_delete and on_update
referential actions are rejected at compile time. Cascade in your application instead, with
something like change cascade_destroy(:subscriptions).
Identities
On a temporal resource, identities become period-aware UNIQUE (... WITHOUT OVERLAPS)
exclusions rather than plain unique indexes. A plain unique index would be wrong both ways. It
would reject the second period of any record, so you couldn't have history at all. And a naive
(email, valid_at) index would allow two records to share a value at the same instant.
The period-aware version means "unique at every instant, with history allowed". You write
identity :unique_email, [:email] like you always do, and it does the right thing.
Authorization
The actor is taken as it is; data is read "as of"
This is the most important thing to understand about authorizing temporal resources. A temporal query reads data as of the query's timestamp, but it takes the actor's attributes exactly as they are on the actor struct you pass in. They are not re-fetched as of the query's instant.
Policy checks fall into two camps, and they're resolved at different times:
- Actor attribute checks, like
actor_attribute_equals/2,actor_present, and any expression reading^actor(:field), are checked against the actor struct in memory, with whatever values it was loaded with. - Data and filter checks, like filter policies,
relates_to_actor_via, and expressions over the resource's own data, are checked as of the query'sas_of.
If you load the actor now but run a query as of some other instant, those two disagree. You'd be
authorizing historical data with the actor's current attributes, or the other way around.
For example, someone who's an admin today passes actor_attribute_equals(:role, :admin)
even while reading data as of last year, when they may not have been an admin at all.
Rule of thumb: fetch the actor as of the same instant you're about to query. Then the actor's attributes and the data you're reading describe the same moment:
as_of = ~U[2026-01-15 00:00:00Z]
# load the actor AS OF the same instant as the query
actor = Ash.get!(MyApp.User, user_id, as_of: as_of)
MyApp.Subscription
|> Ash.Query.as_of(as_of)
|> Ash.read!(actor: actor)Ash can't do this for you. It has no way of knowing that the actor struct you handed it was
loaded at a different point in time than the query. Keeping the actor and the query on the same
as_of is the only way to get authorization decisions that agree with themselves.
We'd like to add support for transparently reloading the actor as of the query's time at some point, but that needs some new work and some new configuration.
(Ash.can? threads as_of onto the subject it builds, and policy filter checks that use
now() are left to the data layer, so they're evaluated at the query's instant rather than the
wall clock. Neither of those changes the fact that the actor struct's own attributes are
whatever you loaded.)
Changes, validations and preparations
Every action on a temporal resource runs as of a point in time, so anything that runs as part of
one can't assume it's happening now. It can't read the wall clock (use now() in expressions,
or the subject's as_of), it can't have side effects that assume the present, and it has to do
any reads or nested actions through Ash, so that as_of gets passed along to them.
Changes, validations and preparations say they meet that bar with the temporal_safe?/1
callback of their behaviour, which defaults to false. Run one that hasn't said so on a
temporal resource, and you'll get Ash.Error.Framework.NotTemporalSafe:
defmodule MyApp.Changes.Slugify do
use Ash.Resource.Change
@impl true
def temporal_safe?(_opts), do: true
@impl true
def change(changeset, _opts, _context), do: # ...
endIf you're using changes, validations or preparations from a package that doesn't declare
temporal_safe?/1 yet, you can mark them as temporal safe in config. This is checked at
compile time:
config :ash, :temporal_safe_modules, [SomePackage.Changes.DoesThing]The built-in changes, validations and preparations are all temporal safe (set_attribute with
&DateTime.utc_now/0 resolves to the write's as_of, just like an attribute default). The
exceptions are the ones that wrap an arbitrary function: before_action, after_action,
before_transaction, after_transaction, and anonymous function changes, validations and
preparations. There's no way to know whether those are safe, so move that logic into a module
that declares temporal_safe?/1 to use it on a temporal resource.
See the changes, validations and preparations guides.
Limitations
ash_postgreson PostgreSQL 19+, orAsh.DataLayer.Ets. Every other data layer reportsAsh.DataLayer.can?(:temporal)asfalse, and a resource that declares itself temporal on one of them won't compile. The two supported data layers behave the same on everything above, in different ways. Postgres keys the tablePRIMARY KEY (id, valid_at WITHOUT OVERLAPS)and splits rows withFOR PORTION OF. ETS puts the period in its storage key and rewrites the versions it affects.Ash.DataLayer.Etswrites aren't transactional. A split deletes the version and then writes both halves, in that order, so someone reading at the same time can see the record missing, but never as two versions at once. Overlaps are checked, not locked, so two creates at the same time can both land.- Periods are ranges over datetimes. A period over dates (validity tracked by the day), naive datetimes, or any other ordered type is refused at compile time.
- No "all of history" reads. Every read is a single point in time, and querying across several periods of the same record at once isn't supported. Doing that properly gets into some mind-bending, timey-wimey territory, and it's something we may take on later.
- Manual actions skip temporal handling. The data layer is never called, so managing periods is up to you.
- No database-level referential actions on
PERIODforeign keys. That's a PostgreSQL rule, so cascade in your application instead.