Encryptor.Ecto.DateTime (Encryptor.Ecto v0.3.0)

Copy Markdown View Source

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
end

Everything 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 ArgumentError naming 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

Types

The options use Encryptor.Ecto.DateTime accepts.

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

opts()

@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

__using__(opts)

(macro)
@spec __using__(opts()) :: Macro.t()

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.

cast(value, params)

@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

validate_declaration!(module, opts)

@spec validate_declaration!(
  module(),
  keyword()
) :: keyword()

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]