An encrypted UTC instant field: Encryptor.Ecto.Binary over the value's
ISO 8601 form (ADR-0001 decision 1).
defmodule Payments.Encrypted.DateTime do
use Encryptor.Ecto.DateTime, vault: Payments.Vault
end
defmodule Payments.Cards.Card do
use Ecto.Schema
schema "cards" do
field :merchant_id, :string
field :verified_at, Payments.Encrypted.DateTime
end
endEverything below the cast/parse pair is Encryptor.Ecto.Binary, called
rather than copied: the same closed option set, the same declared
"table"/"column" context, the same tenant resolution, the same :binary
column, the same exception family, and the vault's bytes stored verbatim.
Read that module for all of it; only the differences are documented here.
The cast/parse pair
cast/2 is Ecto.Type.cast(:utc_datetime, value) - Ecto's own caster - and
the plaintext is DateTime.to_iso8601/1, parsed back with
DateTime.from_iso8601/1.
The textual form is not a choice taken here. cloak_ecto's scalar types
write the same one, and ADR-0004's migration hands the migrator a legacy
plaintext and re-encrypts it verbatim below the schema layer (ADR-0002
decision 3), so a column arriving from Cloak.Ecto.DateTime is readable
through this type only because the two agree about what the bytes say.
A payload that decrypts and then does not parse raises
Encryptor.Ecto.SerializationError with {:unparsable, :utc_datetime}: the
decrypt succeeded, so it is neither an encryption failure nor an integrity
event, which is the row ADR-0001 decision 6 gives that exception. A value
that reaches dump/3 without passing cast/2 - through insert_all/3, say
- raises
ArgumentErrornaming the shape it was given and never the value.
UTC only, and sub-second precision is dropped at the cast
:utc_datetime is the primitive this type wraps, so a cast value is always
in Etc/UTC and dump/3 refuses a DateTime.t/0 in any other zone rather
than storing it. The refusal is the point: the ISO 8601 form of a zoned
datetime carries its offset, DateTime.from_iso8601/1 resolves that to the
equivalent UTC instant, and accepting one would silently drop the zone on
every read instead of on the one write a host can still correct.
Ecto's caster likewise truncates a microsecond field to {0, 0} on the way
in, exactly as it does for a plain :utc_datetime column - before any
encryption, and visibly in the changeset rather than only on the next read.
Everything else
nil is NULL and is not encrypted (decision 7). The column is :binary,
not :utc_datetime - type/1 returns :binary whatever the plaintext was
(decision 2). Encrypted columns are not queryable, sortable or uniquely
indexable (decision 10), so an "expires before" query or an index-backed
ordering over this column is not available and is not quietly approximated
here.
Summary
Functions
Defines an encrypted UTC instant type on the using module.
Casts a value on its way into a changeset. Never encrypts.
Checks a declaration's option set while the declaring module compiles.
Types
@type opts() :: Encryptor.Ecto.Binary.opts()
The options use Encryptor.Ecto.DateTime accepts.
Identical to Encryptor.Ecto.Binary's: this type adds no option and removes
none, and :json belongs to Encryptor.Ecto.Map alone (decision 3).
Functions
Defines an encrypted UTC instant type on the using module.
See Encryptor.Ecto.Binary for the option set; anything outside it raises
here, while the host module is compiling.
@spec cast(term(), term()) :: {:ok, DateTime.t() | nil} | :error
Casts a value on its way into a changeset. Never encrypts.
iex> Encryptor.Ecto.DateTime.cast(~U[2026-09-12 10:20:30Z], %{})
{:ok, ~U[2026-09-12 10:20:30Z]}
iex> Encryptor.Ecto.DateTime.cast("2026-09-12T10:20:30Z", %{})
{:ok, ~U[2026-09-12 10:20:30Z]}
iex> Encryptor.Ecto.DateTime.cast(nil, %{})
{:ok, nil}
iex> Encryptor.Ecto.DateTime.cast("yesterday", %{})
:error
Checks a declaration's option set while the declaring module compiles.
iex> Encryptor.Ecto.DateTime.validate_declaration!(Payments.Encrypted.DateTime,
...> vault: Payments.Vault
...> )
[vault: Payments.Vault]