Encryptor.Ecto.Migrator.Source behaviour (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

How the migrator reads the pre-migration value of a column.

ADR-0002's typespecs name this behaviour with a single callback; ADR-0004 decision 2 fixes what satisfies it and how a plan names one. A plan's from: accepts three things:

from: valueMeaning
A module implementing Ecto.Type (arity-1 load/1)Adapted by Encryptor.Ecto.Migrator.Source.EctoType. This is every cloak_ecto type module and every hand-rolled one
A module implementing Ecto.ParameterizedType (arity-3 load/3)Adapted by Encryptor.Ecto.Migrator.Source.EctoType, with params constructed by the migrator
A module implementing this behaviourUsed as-is. Encryptor.Ecto.Migrator.Source.Plaintext is the one this package ships

resolve!/2 decides between them once, while the plan module is compiling, and load/3 is what the engine calls per row. Nothing here branches on cloak_ecto, imports it, or recognizes one of its messages: the module doing the legacy load is the module the host has been running in production, and this package's correctness obligation on the legacy format is exactly nil (ADR-0004 decision 1).

Why the resolution is a compile-time step

ADR-0002 decision 2 promises that a plan which would fail on row one fails at mix compile. A from: module that can read nothing is exactly that kind of plan, and discovering it on row one of a nine-hundred-thousand-row pass - against live production data, from a shell - is the failure mode the promise exists to remove. resolve!/2 is the source half of it, and it raises CompileError naming the field rather than returning an error tuple, because its caller is a macro and its reader is a compiler.

Why a failure here is data rather than an exception

ADR-0001 decision 6 makes the types raise, which is right for application code and wrong for a migrator that must classify a failure and keep a report. At this boundary a failed read is {:error, reason}, the migrator classifies the row :undecryptable, and ADR-0002 decision 11's default is still to halt the pass. That conversion is the adapter's job and is described in Encryptor.Ecto.Migrator.Source.EctoType; a module implementing this behaviour directly is contracted to return the tuple itself, and a raise from one is a contract violation this module deliberately does not launder.

The deferred-decryption unwrap

A zero-arity function returned by a source is invoked once, and its result is the plaintext. Deferred decryption is a real convention among legacy types - cloak_ecto's :closure option is exactly it - and a source that returned a closure would otherwise have the migrator re-encrypt the function rather than the value. The rule is generic to this contract rather than cloak handling, which is why it lives in load/3 and not in the adapter. Invoked once means once: a closure that returns another closure yields that closure as the value, because a loop here would be this package guessing at a convention nobody wrote down.

The redaction rule, at this boundary

The unwrapped value is never logged, inspected, or put in an error (ADR-0002 decision 11, ADR-0001 decision 6). Two consequences are visible in the code below and are deliberate:

  • a result that is neither {:ok, term} nor {:error, reason} becomes {:error, :invalid_source_result} rather than falling through to a CaseClauseError, whose message would carry the unmatched term - which is to say, the plaintext;
  • a rescued exception is reported as {:error, {:raised, Module}}, the raising module's name and nothing else. A foreign legacy type's exception is a struct this package cannot inspect for secrets and whose Inspect implementation it does not control, so the message and the struct both stay out of the reason. The cost is real - an operator reading a report learns which module failed but not what it said - and it is the same trade Encryptor.Ecto.Error already makes by redacting on shape rather than on provenance.

Summary

Types

The identifying options resolve!/2 reads to build its message.

What the migrator hands a source for one row.

A from: module resolved to a source and the params the resolution fixed.

Callbacks

Reads the pre-migration value of one column of one row.

Functions

Calls a resolved source for one row, and unwraps a deferred decryption.

Decides which source reads a from: module, at plan compile time.

Invokes a zero-arity function returned by a source, once, and returns its result as the plaintext. Any other term is already the plaintext.

Whether a from: module is provably one of this package's own vault-backed types.

Types

field_opts()

@type field_opts() :: [
  schema: module(),
  field: atom(),
  file: String.t(),
  line: non_neg_integer()
]

The identifying options resolve!/2 reads to build its message.

params()

@type params() :: map()

What the migrator hands a source for one row.

ADR-0002 decision 3: the migrator works below the schema layer and supplies context explicitly, so the params are constructed per field rather than read off a schema declaration.

resolved()

@type resolved() :: {module(), params()}

A from: module resolved to a source and the params the resolution fixed.

The second element is merged over the migrator's per-field params by load/3, which is how the adapter learns which module it is adapting without the plan carrying that detail into every row.

Callbacks

load(binary, params)

@callback load(binary(), params()) :: {:ok, term()} | {:error, term()}

Reads the pre-migration value of one column of one row.

Returns {:ok, plaintext} or {:error, reason}; it does not raise for a value it cannot read.

Functions

load(arg, value, params)

@spec load(resolved(), binary(), params()) :: {:ok, term()} | {:error, term()}

Calls a resolved source for one row, and unwraps a deferred decryption.

The params the resolution fixed are merged over the migrator's per-field params, so an adapter's own keys win over a field key of the same name.

resolve!(module, field_opts)

@spec resolve!(module(), field_opts()) :: resolved()

Decides which source reads a from: module, at plan compile time.

Raises CompileError naming the field when the module cannot be loaded, or when it implements neither this behaviour, Ecto.ParameterizedType, nor Ecto.Type.

iex> alias Encryptor.Ecto.Migrator.Source
iex> Source.resolve!(Source.Plaintext, schema: My.Schema, field: :notes)
{Encryptor.Ecto.Migrator.Source.Plaintext, %{}}

unwrap(value)

@spec unwrap(term()) :: {:ok, term()} | {:error, term()}

Invokes a zero-arity function returned by a source, once, and returns its result as the plaintext. Any other term is already the plaintext.

A raise from the closure is converted the way the adapter converts a raise from a load: the closure is the deferred half of the load it came from, so a legacy type that defers its decrypt must not be able to halt the pass in a way the same type decrypting eagerly would not.

vault_backed?(module, field_opts)

@spec vault_backed?(module(), field_opts()) :: boolean()

Whether a from: module is provably one of this package's own vault-backed types.

This is the whole of what ADR-0004's proposed amendment of 2026-08-28 (Q2) lets a plan stay silent about. A field whose from: answers true here has its authentication proven by the package that wrote the bytes, so it needs no source_authenticated: declaration; every other from: is the host's own legacy reader, this package cannot tell an AEAD cipher from a stream cipher by looking (decision 1), and Encryptor.Ecto.Migration asks at mix compile.

Proven by the params Encryptor.Ecto.Binary.init/2 freezes - six keys, :vault and :legacy among them - rather than by the __encryptor_ecto__/1 marker Encryptor.Ecto.Declarations uses. The marker is defined by Encryptor.Ecto.Binary's __using__ alone, so a type written with use Encryptor.Ecto.String or use Encryptor.Ecto.Map does not carry it while its init/1 delegates to exactly that function; recognising the frozen shape covers all three, and widening the marker would change which fields Declarations.check_unique!/1 sees, which is ADR-0001's contract rather than this record's.

Nothing is inferred from a module's name, its dependencies, or the shape of its bytes, and the answer is false for everything this predicate cannot answer positively - including an init/1 that raises, which a foreign parameterized type's may well do when handed a schema and a field it has never seen. False is the safe answer: it asks the host to declare.

iex> alias Encryptor.Ecto.Migrator.Source
iex> Source.vault_backed?(Source.Plaintext, schema: My.Schema, field: :notes)
false