The declared encryption contexts a host's schemas carry, and the check that no two of them are the same (ADR-0001 decision 4, acceptance amendment 5).
Every encrypted field freezes a declared "table" and "column" at
declaration time, and every message it writes is authenticated under that
pair. Two fields that share one declared pair are therefore mutually
substitutable: bytes written by one decrypt cleanly when loaded by the
other, under the same tenant key. That is exactly the property the
encryption context exists to deny, and nothing about the declarations
themselves makes the overlap visible - the two modules need never mention
each other.
Why this is a start-time check rather than a compile-time one
The obvious shape is a module attribute accumulated at compile time. It
cannot work: an accumulator sees only the declarations that reach one
compilation unit, and the two colliding declarations are, by construction,
in modules that never reference each other. The check has to run at a point
where the whole set is knowable, and that point is start time, over the
modules an application's .app file lists.
So there is no registry process and no persisted state here - nothing to
start, nothing to keep in sync. check_unique!/1 is a pure function over
loaded modules, and the host calls it where it wants the failure:
defmodule Payments.Application do
use Application
@impl Application
def start(_type, _args) do
:ok = Encryptor.Ecto.Declarations.check_unique!(apps: [:payments])
Supervisor.start_link(children(), strategy: :one_for_one, name: Payments.Supervisor)
end
endCalling it from start/2 makes a collision a boot failure on the first
deploy that introduces it, which is the only moment it is free to fix. A
host that would rather not fail its boot can call the same function from a
test instead; what it must not do is leave the pair unchecked, because the
overlap is silent in every other way.
What counts as a collision
Two declarations collide when they share a declared {table, column} pair
and do not describe the same physical column. The second half matters:
two Ecto schemas over one table - a read model, a partial schema, an
archival twin - legitimately declare the same field, and they are not
substitutable for each other because they are each other. A check that
failed those would be a check hosts turn off. Physical identity is the
schema's source table and the field's own :source column, so the genuine
accident (two different columns pinned to one declared pair) still collides,
and the legitimate duplicate does not.
Note what is not part of the comparison: the vault. Two fields sharing a declared pair on different vaults cannot actually decrypt each other's bytes, but a vault is a deployment-time binding that a later refactor can make the same, and the declared pair is the durable property. The check is the stricter one on purpose.
Renaming, and what a rename costs
Since the declared values are frozen at declaration, renaming the physical
table or column is free: pin the old values with :table and :column and
stored rows stay readable.
defmodule Payments.Cards.Card do
use Ecto.Schema
# The physical table is `payment_cards` now; the declared context is
# still what the rows were written under.
schema "payment_cards" do
field :pan, Payments.Encrypted.Binary, table: "cards", column: "pan"
end
endChanging a declared value is the opposite: it invalidates every row in the column and is an R3 migration (ADR-0002). The rename that used to be the expensive one is the cheap one, and the cheap-looking one is the expensive one - which is the whole point of freezing them.
Summary
Functions
Raises unless every declared {table, column} pair is unique.
Lists every encrypted field declaration in scope, sorted by declared pair.
Types
@type declaration() :: %{ schema: module(), field: atom(), type: module(), table: String.t(), column: String.t(), source: String.t() | nil, source_column: atom() }
One encrypted field declaration.
:table and :column are the frozen declared values - what goes into the
encryption context. :source and :source_column are the physical table
and column, which are only the same strings when the declaration was derived
rather than pinned.
Where to look for declarations.
:apps names OTP applications and reads every module in each one's .app
file - the ordinary host call. :schemas names schema modules directly,
which is what a test wants. At least one of the two is required: there is no
"check everything" default, because a library's opinion about which
applications a host meant is always wrong.
Functions
@spec check_unique!(scope()) :: :ok
Raises unless every declared {table, column} pair is unique.
Returns :ok when it is. See the moduledoc for what counts as a collision
and for where a host calls this.
iex> Encryptor.Ecto.Declarations.check_unique!(schemas: [Encryptor.Ecto.TestSchemas.Card])
:ok
@spec list(scope()) :: [declaration()]
Lists every encrypted field declaration in scope, sorted by declared pair.
Useful on its own: it is the answer to "what does this deploy consider encrypted, and under what context", which is otherwise spread across every schema in the application.
iex> Encryptor.Ecto.Declarations.list(schemas: [Encryptor.Ecto.TestSchemas.Card])
...> |> Enum.map(&{&1.table, &1.column})
[{"cards", "holder_name"}, {"cards", "metadata"}, {"cards", "notes"}, {"cards", "pan"}]