The compile-time DSL for a migration plan (ADR-0002 decision 2).
A plan module names one repo and, per schema, the fields to rewrite and how the tenant is resolved for its rows:
defmodule MyApp.Encryption.CloakMigration do
use Encryptor.Ecto.Migration, repo: MyApp.Repo
rewrite MyApp.Accounts.Customer do
tenant_from :account_id
field :tax_id, from: MyApp.Cloak.Encrypted.Binary, to: MyApp.Encrypted.Binary
field :notes, from: MyApp.Cloak.Encrypted.String, to: MyApp.Encrypted.String
end
rewrite MyApp.Reference.Code do
tenant :none
field :value, from: MyApp.Cloak.Encrypted.Binary, to: MyApp.Encrypted.Binary
end
endThe module compiles to a Encryptor.Ecto.Migrator.Plan.t/0, handed over by
the __plan__/0 callback this DSL defines. Encryptor.Ecto.Migrator.run/2
and verify/2 take the plan module, so a host names a file it can read.
Why a plan is code
Everything in a plan is code already: the from and to sides are type
modules, and which fields are encrypted is exactly the kind of fact that
should be reviewed in a diff, versioned with the schema it describes, and
deleted in a named commit when the migration is finished. Configuration
would make the most consequential operation this package performs invisible
to code review - a change to which columns get rewritten, against live
production data, arriving without a diff. So there is no config path here,
and adding one is not a small convenience: it is the property being refused.
The compile-time checks are the deliverable
ADR-0002 decision 2 promises that a plan which would fail on row one fails
at mix compile. Row one is against live production data, from a shell,
usually late in a change window, and a typo discovered there has already
cost the rehearsal. So every check that can be made against the schema and
the modules is made while the plan module compiles, and each failure is a
CompileError naming the schema and field it came from:
- the schema is a real
Ecto.Schema, and everyfieldandinto:names a field on it; tenant_fromnames a real column, and atenantmodule resolves;- every
from:module can read the bytes -resolve!/2decides betweenEncryptor.Ecto.Migrator.Source,Ecto.ParameterizedTypeandEcto.Typeand refuses a module that is none of them; - every
to:module can both load and dump at one consistent arity. The two sides are not symmetric even though the record states them in one clause: afrom:module is the host's own legacy code and only has to load, while ato:module receives the rewritten bytes and must dump as well. Ato:that cannot dump fails on row one just as loudly, and nothing else in this package would have caught it.
Code.ensure_compiled/1 precedes each of those, so a plan may name a module
from its own application: the check waits for the parallel compiler rather
than answering "not loaded" on a race it would lose intermittently.
from: and to: may be the same module
A field moving from tenant: :none to tenant: :scope, or gaining a
:context pair, is a context change and therefore a full rewrite even
though no type module changed. The migrator constructs both sides' params
itself (ADR-0002 decision 3), so the plan expresses this by naming the same
module on both sides; a migrator built on schema declarations could only
ever express the type-module case. Nothing here rejects from: equal to
to: for that reason.
Adopting encryption on a plaintext column
into: names a different target column, which is the backfill leg of the
expand/backfill/contract dance a never-encrypted column needs (ADR-0002
decision 8): plaintext lives in a text column, ciphertext must live in a
binary one, and the DDL either side of the backfill is the host's own
migration. Both columns are checked against the schema; which one is wider
is not this package's business.
What the legacy cipher does not prove
A legacy stream cipher has no authentication tag, so a failed decrypt is not a signal: the bytes decrypt to something, and the migrator would re-encrypt that something into authenticated storage, permanently, and report it as a success (ADR-0004 decision 3). Two field options exist for that:
source_authenticated: falseacknowledges it. The report then counts that field's rows:migratable_unverifiedrather than:migratable, in a dry run and in a verification alike, so no evidence claims verification that never happened; and the pass refuses to run in--mode writefor that field unlessvalidate:is declared with it.validate:is a host-supplied(term() -> boolean())applied to the loaded plaintext before it is re-encrypted (decision 3b) - a tax identifier is nine digits, a serialized map parses, a kept legacy hash column recomputes (decision 3c). A row it rejects is:undecryptable, handled like any other failure. There is no built-in generic validator: this package cannot know what a valid value looks like, and a printable?/UTF-8? check would be reassurance rather than a control.
Silence is allowed only where authentication is provable
ADR-0004's proposed amendment of 2026-08-28 answers Q2. A field whose
from: is one of this package's own vault-backed types needs no
declaration, because the package that wrote those bytes authenticates them
and Encryptor.Ecto.Migrator.Source.vault_backed?/2 can prove it - that is
the context-change case above, where from: and to: name the same module
with different params. Every other from: is the host's own legacy reader:
a cloak cipher module, a legacy load/1, an unknown Source. This
package's correctness obligation on that format is nil (decision 1) and it
cannot tell an AEAD cipher from a stream cipher by looking, so it asks - at
mix compile, where the question is cheap - and such a field must declare
source_authenticated: explicitly, true or false. true is not a
capability this package checks; it is the host asserting that someone looked
at the legacy cipher, in a line a reviewer sees in the diff.
A host upgrading across this change sees its plan stop compiling, with a message naming the field and saying what to write.
Summary
Callbacks
The compiled plan. Defined by use Encryptor.Ecto.Migration.
Functions
Declares a plan module. Takes repo:, and nothing else yet.
Declares one field to rewrite: from:, to:, and optionally into:,
source_authenticated: and validate:.
Opens a rewrite of one schema. Its body declares a tenant strategy and one or more fields.
Declares the tenant strategy: :none for a global field, or an
Encryptor.Ecto.TenantContext module.
Declares that the tenant is read off the named column of each row.
Types
@type field_spec() :: [ from: module(), to: module(), into: atom() | nil, source: Encryptor.Ecto.Migrator.Source.resolved(), source_authenticated: boolean(), validate: (term() -> boolean()) | nil ]
One field's rewrite.
:from and :to are the modules the host writes. :into names a different
target column for the backfill leg of an adoption migration, and is nil
for the ordinary data-only case. :source is derived rather than written:
it is what Encryptor.Ecto.Migrator.Source.resolve!/2 decided about :from
while the plan compiled, kept here because ADR-0004 decision 2 fixes that
resolution as a compile-time step and re-deriving it at run time would put
the decision back where the record took it from.
:source_authenticated and :validate are ADR-0004 decision 3, and
:source_authenticated is always present in the compiled spec even where
the plan did not write it: an undeclared field is true only because the
compile-time check proved it (see "What the legacy cipher does not prove"),
so the engine reads one key rather than deciding provability again per row.
@type meta() :: [file: String.t(), line: non_neg_integer()]
Where a compile-time failure came from, for the CompileError.
Callbacks
@callback __plan__() :: Encryptor.Ecto.Migrator.Plan.t()
The compiled plan. Defined by use Encryptor.Ecto.Migration.
Functions
Declares a plan module. Takes repo:, and nothing else yet.
Declares one field to rewrite: from:, to:, and optionally into:,
source_authenticated: and validate:.
source_authenticated: is required rather than optional wherever the
from: type is not one of this package's own - see "What the legacy cipher
does not prove".
Opens a rewrite of one schema. Its body declares a tenant strategy and one or more fields.
Declares the tenant strategy: :none for a global field, or an
Encryptor.Ecto.TenantContext module.
Declares that the tenant is read off the named column of each row.