The sanctioned per-key-process layer (design §5.5, §8; implementation.md
§7): hashing, the wrapper message format, the user-facing key behaviour, and
the public key-addressed API. Built purely on the public Fief.Vnode
behaviour and Fief.Router — the runtime half is Fief.Key.VnodeImpl, the
per-key processes are Fief.Key.Server, and the ⊨-rule state machines it
embeds are Fief.Transfer (the key-generic transfer substrate).
Runtime floor
The Key layer requires Erlang/OTP 28+: the transfer freeze advisory
(handle_freeze/2) is delivered as an EEP 76 priority message, so it skips
a flooded key's backlog. Fief.Key.VnodeImpl enforces this at instance
boot. (Elixir's floor stays ~> 1.20; it has no OTP-version mechanism.)
Hashing (Decision, implementation.md §7)
The key→vnode map is rem(hasher.hash_key(key), partitions), where hasher
is a pluggable Fief.Hasher module (the :hasher impl opt, defaulting to
Fief.Hasher.Default — :erlang.phash2/2 over binaries). The mapping is
immutable per instance (design §9); a future algorithm is a new module, not a
version integer. The default hasher accepts binaries only; a non-binary key
raises unless the instance configures a custom :hasher — the same
forced-explicit-canonicalization the old key_encoder contract gave.
A hasher is a module, never a fun, because the module atom is the
fingerprint: Fief.Key.VnodeImpl's config_fingerprint returns
{:hasher, module} — exactly and only what gates cross-node key→vnode
agreement (sweep rates, pend bounds, blob caps are per-node tuning and
deliberately absent). Behavioral stability of a custom hasher is the user's
contract (Fief.Hasher).
The algorithm lives in Fief.Hasher — the neutral substrate shared with
Fief.Cache, the other key-addressed surface.
Wrapper format (Decision)
Key identity is {key_module, key} — multiple use Fief.Key modules
coexist in one instance, sharing the vnode space. The router payload is
{:key, key_module, key, msg}; the hash covers the key alone (the
module is identity, not placement). Only Fief.Key.VnodeImpl ever pattern
matches this tuple — to the framework it is opaque payload.
Public surface (Decision)
Fief.Key.call(instance, key_module, key, msg, opts) /
cast/4 / owner_of/2 / reply/2, plus use Fief.Key sugar on the key
module: MyKey.call(instance, key, msg, opts), MyKey.cast(instance, key, msg).
The vnode-level Fief.call/4 stays untouched — key hashing happens here,
caller-side, reading the instance's immutable router config (partitions +
the impl's hasher opt).
The key behaviour
defmodule MyApp.Session do
use Fief.Key, on_fence: :terminate # | :continue | :callback
@impl true
# Every incarnation starts here. source: :fresh | {:residual, blob}.
# ctx.origin says why this incarnation exists (informational).
def init(key, :fresh, _ctx), do: {:ok, load_or_create(key)}
def init(_key, {:residual, blob}, _ctx), do: {:ok, deserialize(blob)}
@impl true
def handle_message(msg, from, state),
do: {:reply, reply, state} # serve (routed)
# optional; your own mailbox — timers, PubSub, monitors (NOT validated)
@impl true
def handle_info(:flush, state), do: {:noreply, flush(state)}
# optional; freeze advisory (priority — skips your backlog). This
# incarnation WILL stop: extract, your own return, or the deadline.
@impl true
def handle_freeze(_deadline_ms, state), do: {:noreply, start_bundling(state)}
@impl true
def extract_state(state), do: {:ok, blob} # freeze
# only consulted for on_fence: :callback
@impl true
def handle_fence(deadline_ms, state), do: {:stop, :fenced}
endKey-process lifecycle in one line (implementation.md §7; superseded by
docs/key-redesign.md): init (every incarnation — fresh, escheat,
transfer, resume) → serve (handle_message routed + validated;
handle_info your own mailbox, unvalidated) → handle_freeze advisory
(optional) → extract_state + retire (freeze) | {:stop, …} | die-with-node.
The open mailbox (docs/key-redesign.md §2.2, §3.2)
A key is "a GenServer that fief places, moves, and fences": the framework
claims its own reserved messages (the fief_key_-prefixed tags) and routes
everything else to your optional handle_info/2 — your timers, PubSub,
monitors, anything keyed to self(). Two facts bound what this buys you:
handle_messageis validated;handle_infois not. Routed messages arrive only while this node owns the vnode under a live lease; your own ingress can fire while ownership is moving or gone (bounded by the fence kill under:terminate). Mutate state fromhandle_infoonly in ways that tolerate that, or route the event throughcast/4— which is also the only ingress that reaches a non-resident key (cold start on touch); a subscription only fires while you happen to be resident.- Process-scoped resources die with each incarnation.
init/3runs on every incarnation, so it is where you re-establish subscriptions/timers/ monitors — nothing rides the blob except whatextract_state/1put there. Do not register a key under a global name: the only address of a key is{key_module, key}through the routed API.
A single init/3 replaces the old init/2 + inject_state/2 + escheat/2
split: a fresh owner after donor death cannot distinguish "existed elsewhere
before" from "never existed," so init was already necessarily the
locate-durable-truth path. source is :fresh or {:residual, blob} (the
blob is exactly an extract_state/1 product); ctx.origin
(:first_touch | :escheat | :transfer | :resume) is informational, never
procedural — correct code may branch on it for logging/metrics but must be
correct ignoring it.
The two-phase freeze (docs/key-redesign.md §2.4, §4.2)
An open mailbox means a key can be flooded, and a naive freeze would then stall a transfer indefinitely (extract queues behind the flood; the donor never drains). Freeze is therefore two-phase, backstopped by a deadline:
- Advisory —
handle_freeze/2, a priority message (skips the backlog). Pure information: the user picks the policy (drain, bundle, ship-now, or die). Optional; dropped if not exported. - Extract —
:fief_key_extractin normal mailbox order. Healthy keys quiesce exactly as before: in-flight validated callers are served, nobody times out. - Deadline — a donor-side per-freeze timer (
extract_deadlinevnode opt, default 5s). At expiry every key that hasn't extracted is killed (:kill, untrappable) and converges through the existing died-before-extract path (no residual → the recipient escheats). The deadline is not optional — it is the only rung that handles a key stuck inside a callback, where no message priority can help.
Fence modes (design §8)
on_fence: is per key module. :terminate (default) — the unconditional
kill handles it; the only mode under which guarantee 1 covers live
processes and their side effects. :continue — the key's servers live
under the instance's limbo supervision (Fief.Key.Limbo, outside the
agent's link tree) from birth, so they survive the fence kill; stale
survivors are reaped when a new impl incarnation for their vnode
initializes. Forfeits guarantee 1 for side effects. :callback — the
fence advisory fans handle_fence/2 out to the key's servers (also
limbo-supervised, so a {:continue, state} answer survives the kill);
{:stop, reason} stops. Advisory, under the framework's deadline — never
load-bearing.
Summary
Types
The extract_state/1 product; a {:residual, blob} source hands it back
verbatim. Mirrors Fief.Transfer.blob() — the transfer substrate's opaque
per-key state term (Fief.Key depends on Fief.Transfer, never the
reverse).
Callback context: %{instance:, node_id:, vnode_id:, origin:}.
A user key: a binary, or any term the instance's :hasher accepts.
Why this incarnation exists — informational, never procedural.
:first_touch / :escheat pair with :fresh; :transfer (arrival via
grant/push) / :resume (local rehydration after an aborted freeze) pair
with {:residual, blob}.
What init/3 starts from: :fresh (no in-memory state survived — locate
durable truth) or {:residual, blob} (an extract_state/1 product handed
back on transfer arrival or local resume). {:residual, blob} rather than
blob | nil so a legitimately-nil user blob stays unambiguous.
User key-process state. Opaque to fief.
Callbacks
Freeze: serialize the state for handover. Runs last; the process retires.
Only for on_fence: :callback: the lease is lost — flush, demote to
read-only ({:continue, state}, survives the kill via limbo), or stop.
deadline_ms is how long the advisory has before the fence completes;
advisory, never load-bearing.
Optional. The transfer freeze advisory — a priority message (EEP 76), so
it arrives ahead of any mailbox backlog. It means this incarnation will
stop: via extract_state/1 (draining first), your own {:extract, _} /
{:stop, _, _}, or the donor-side deadline kill (deadline_ms from now).
There is no path back to serving. Returns
Optional. Everything in your mailbox that is not a framework-reserved
message: your own timers, PubSub broadcasts, monitor :DOWNs — anything
keyed to self(). Not ownership-validated (see the moduledoc): it fires
whenever your process is alive, including while ownership is moving or gone.
Absent, non-reserved messages are dropped silently.
Serve one message. from is opaque (nil for casts); {:reply, reply, state}
answers the caller, {:noreply, state} leaves the caller to
Fief.Vnode.reply/2 from user code or time out, {:stop, reason, state}
retires the process after this message (the next message cold-starts).
Every incarnation starts here — fresh start, escheat, transfer arrival, or
local resume. source is :fresh (locate durable truth) or
{:residual, blob} (rehydrate from an extract_state/1 product);
ctx.origin says why (informational). {:stop, reason} refuses the start.
Functions
Call key (owned by key_module) on instance and await the reply:
{:ok, reply} | {:error, reason} — the Fief.Router.call/4 shape; opts
takes :timeout, covering the whole :moved-retry loop. Runs in the
caller. Raises ArgumentError for structural misuse (no vnode impl, a
non-binary key the instance's :hasher rejects) — those are caller bugs,
not runtime conditions.
Fire-and-forget to key (owned by key_module) on instance.
The presumed owner of key — hint-grade, like Fief.owner_of/2.
Answer a stashed from — a defdelegate to Fief.Vnode.reply/2, so a key
answering a caller from a spawned task or a later incarnation (a from
bundled into the blob at freeze) stays within the Fief.Key namespace.
Location-transparent: works cross-node. Only meaningful for a from from a
call (not a cast, whose from is nil).
The vnode key maps to on instance, per the instance's immutable
hasher/partitions (read from the router config written once at instance
boot): rem(hasher.hash_key(key), partitions).
Types
@type blob() :: Fief.Transfer.blob()
The extract_state/1 product; a {:residual, blob} source hands it back
verbatim. Mirrors Fief.Transfer.blob() — the transfer substrate's opaque
per-key state term (Fief.Key depends on Fief.Transfer, never the
reverse).
@type ctx() :: %{ instance: atom(), node_id: term(), vnode_id: non_neg_integer(), origin: origin() }
Callback context: %{instance:, node_id:, vnode_id:, origin:}.
@type key() :: term()
A user key: a binary, or any term the instance's :hasher accepts.
@type origin() :: :first_touch | :escheat | :transfer | :resume
Why this incarnation exists — informational, never procedural.
:first_touch / :escheat pair with :fresh; :transfer (arrival via
grant/push) / :resume (local rehydration after an aborted freeze) pair
with {:residual, blob}.
@type source() :: :fresh | {:residual, blob()}
What init/3 starts from: :fresh (no in-memory state survived — locate
durable truth) or {:residual, blob} (an extract_state/1 product handed
back on transfer arrival or local resume). {:residual, blob} rather than
blob | nil so a legitimately-nil user blob stays unambiguous.
@type state() :: term()
User key-process state. Opaque to fief.
Callbacks
Freeze: serialize the state for handover. Runs last; the process retires.
@callback handle_fence(deadline_ms :: non_neg_integer(), state()) :: {:stop, term()} | {:continue, state()}
Only for on_fence: :callback: the lease is lost — flush, demote to
read-only ({:continue, state}, survives the kill via limbo), or stop.
deadline_ms is how long the advisory has before the fence completes;
advisory, never load-bearing.
@callback handle_freeze(deadline_ms :: non_neg_integer(), state()) :: {:noreply, state()} | {:extract, state()} | {:stop, term(), state()}
Optional. The transfer freeze advisory — a priority message (EEP 76), so
it arrives ahead of any mailbox backlog. It means this incarnation will
stop: via extract_state/1 (draining first), your own {:extract, _} /
{:stop, _, _}, or the donor-side deadline kill (deadline_ms from now).
There is no path back to serving. Returns:
{:noreply, state}— keep draining;extract_state/1arrives in mailbox order. The idiomatic bundling response flips a flag here and cheaply buffers{msg, from}pairs inhandle_messagethereafter (bounded bymax_blob_size— an over-cap residual is dropped and the key escheats).{:extract, state}— ship this state now: the server runsextract_state/1immediately and stops; the mailbox backlog dies with the process (callers time out). For "my backlog is worthless but my in-memory state is not yet durable."{:stop, reason, state}— die now, no residual; the recipient escheats from durable truth. For "durable truth is current; no transfer needed."
Dropped without calling user code if not exported.
Optional. Everything in your mailbox that is not a framework-reserved
message: your own timers, PubSub broadcasts, monitor :DOWNs — anything
keyed to self(). Not ownership-validated (see the moduledoc): it fires
whenever your process is alive, including while ownership is moving or gone.
Absent, non-reserved messages are dropped silently.
@callback handle_message(msg :: term(), from :: term() | nil, state()) :: {:reply, term(), state()} | {:noreply, state()} | {:stop, term(), state()}
Serve one message. from is opaque (nil for casts); {:reply, reply, state}
answers the caller, {:noreply, state} leaves the caller to
Fief.Vnode.reply/2 from user code or time out, {:stop, reason, state}
retires the process after this message (the next message cold-starts).
Every incarnation starts here — fresh start, escheat, transfer arrival, or
local resume. source is :fresh (locate durable truth) or
{:residual, blob} (rehydrate from an extract_state/1 product);
ctx.origin says why (informational). {:stop, reason} refuses the start.
Functions
@spec call(atom(), module(), key(), term(), keyword()) :: {:ok, term()} | {:error, Fief.Router.call_error()}
Call key (owned by key_module) on instance and await the reply:
{:ok, reply} | {:error, reason} — the Fief.Router.call/4 shape; opts
takes :timeout, covering the whole :moved-retry loop. Runs in the
caller. Raises ArgumentError for structural misuse (no vnode impl, a
non-binary key the instance's :hasher rejects) — those are caller bugs,
not runtime conditions.
@spec cast(atom(), module(), key(), term()) :: :ok | {:error, Fief.Router.call_error()}
Fire-and-forget to key (owned by key_module) on instance.
The presumed owner of key — hint-grade, like Fief.owner_of/2.
Answer a stashed from — a defdelegate to Fief.Vnode.reply/2, so a key
answering a caller from a spawned task or a later incarnation (a from
bundled into the blob at freeze) stays within the Fief.Key namespace.
Location-transparent: works cross-node. Only meaningful for a from from a
call (not a cast, whose from is nil).
@spec vnode!(atom(), key()) :: non_neg_integer()
The vnode key maps to on instance, per the instance's immutable
hasher/partitions (read from the router config written once at instance
boot): rem(hasher.hash_key(key), partitions).