Pluggable Serializers
Built-in JSON, MessagePack, CBOR, signed, and encrypted serializers, plus the cross-SDK payload-codec chain for compression and encryption.
Built-in JSON, MessagePack, CBOR, signed, and encrypted serializers, plus the cross-SDK payload-codec chain for compression and encryption.
Task arguments and results are serialized before they reach storage. The Rust
core stores every payload as an opaque byte blob, so the codec is the binding's
own concern — and where a binding makes it pluggable, producers and workers
just need to agree on the same Serializer.
The serializer is never negotiated at runtime. Where it is configurable it is
set once per Queue, and a job enqueued with one serializer can only be
decoded by a worker using a compatible one.
The default is SmartSerializer — MessagePack for plain data (dicts, lists,
strings, numbers, booleans, None, tuples), with an automatic
CloudpickleSerializer fallback for anything MessagePack can't encode
(lambdas, closures, custom class instances). No configuration needed.
The default is JsonSerializer — human-readable and easy to debug. Other
built-ins: MsgpackSerializer, CborSerializer, SignedSerializer,
EncryptedSerializer.
The default is JsonSerializer — Jackson-backed, human-readable and easy to
debug. Other built-ins: MsgpackSerializer, CborSerializer,
SignedSerializer, EncryptedSerializer.
There is nothing to pick. A Rust payload is always the wire CBOR envelope
flexiq_core::wire::encode_call writes — the one format the cross-SDK contract
pins — and the sections below name what stands in for each option.
Encodes plain data via MessagePack — fast and compact — and transparently
falls back to CloudpickleSerializer for anything MessagePack can't encode.
A one-byte tag on each payload records which codec produced it, so loads()
always picks the right path.
from flexiq import Queue
queue = Queue() # uses SmartSerializerHandles lambdas, closures, and complex Python objects unconditionally —
every payload goes through cloudpickle, skipping SmartSerializer's
MessagePack fast path. Use it directly for that predictability, or as the
inner serializer for EncryptedSerializer / SignedSerializer.
from flexiq import CloudpickleSerializer, Queue
queue = Queue(serializer=CloudpickleSerializer())No list to give: one codec, always on, never selected. Read the sections below
as "what stands in for this instead" — the only two with a real Rust answer are
CborSerializer, which is what you are already using, and
Custom serializers, where serde takes the seam's place.
Human-readable JSON payloads. Useful for debugging, cross-language interop, or when arguments and results are plain types.
from flexiq import JsonSerializer, Queue
queue = Queue(serializer=JsonSerializer())import { Queue, JsonSerializer } from "@byteveda/flexiq";
new Queue({ dbPath: "flexiq.db", serializer: new JsonSerializer() });import org.byteveda.flexiq.FlexiQ;
import org.byteveda.flexiq.serialization.JsonSerializer;
FlexiQ flexiq = FlexiQ.builder()
.sqlite("flexiq.db")
.serializer(new JsonSerializer())
.open();There is no serializer to choose. A Rust task's payload is the wire CBOR
envelope flexiq_core::wire::encode_call writes, and #[flexiq::task] encodes
each parameter through it — what you control is what your argument types
serialize to.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Order {
order_id: String,
amount_cents: i64,
}
#[flexiq::task]
fn charge(order: Order) -> flexiq::Outcome<()> {
println!("charging {} {} cents", order.order_id, order.amount_cents);
Ok(())
}A parameter needs Serialize to be enqueued and Deserialize to be run, so
both derives are load-bearing.
Compact binary serialization — smaller payloads than JSON, cross-language compatible.
from flexiq import MsgPackSerializer, Queue
queue = Queue(serializer=MsgPackSerializer())import { Queue, MsgpackSerializer } from "@byteveda/flexiq";
new Queue({ dbPath: "flexiq.db", serializer: new MsgpackSerializer() });import org.byteveda.flexiq.FlexiQ;
import org.byteveda.flexiq.serialization.MsgpackSerializer;
FlexiQ flexiq = FlexiQ.builder()
.sqlite("flexiq.db")
.serializer(new MsgpackSerializer())
.open();Also CBOR — the codec is fixed, so there is no more compact format to opt into.
The size knob a Rust caller does have is the shape of the arguments: an integer
travels in CBOR's shortest form, while an f32 is widened to a 64-bit float
because the wire contract pins that width for every float, so the narrower type
buys nothing on the wire.
msgpack ships as a core flexiq dependency, so no extra install is
required — pip install flexiq[msgpack] exists only for discoverability if
you want to spell it out in your own requirements file.
MsgPackSerializer only handles basic types: dicts, lists, strings,
numbers, booleans, and None. It does not support lambdas, closures, or
arbitrary Python objects. Use CloudpickleSerializer when you need to pass
complex objects.
@msgpack/msgpack ships as a core dependency — no extra install required.
MsgpackSerializer's org.msgpack:jackson-dataformat-msgpack dependency is
compileOnly — add it to your build explicitly.
One shape is worth fixing while you are thinking about size. A Vec<u8> is a
serde sequence, so it encodes as an array of integers — one CBOR head per
byte. Annotating it with serde_bytes routes it through the serializer's
byte-string arm instead, which is a single head and the length. The crate's own
wire test reads such a value back as a serde_bytes::ByteBuf for exactly that
reason.
Binary serialization writing the 0x02 wire-envelope tag — the default
format for tasks produced or consumed by another FlexiQ SDK. Unlike JSON,
big integers, datetime/Date, bytes, and decimals round-trip losslessly
across languages.
from flexiq import CborSerializer, Queue
queue = Queue(serializer=CborSerializer())import { Queue, CborSerializer } from "@byteveda/flexiq";
new Queue({ dbPath: "flexiq.db", serializer: new CborSerializer() });import org.byteveda.flexiq.FlexiQ;
import org.byteveda.flexiq.serialization.CborSerializer;
FlexiQ flexiq = FlexiQ.builder()
.sqlite("flexiq.db")
.serializer(new CborSerializer())
.open();This one the Rust SDK has, and it is not optional: every payload carries the
0x02 envelope tag. crates/flexiq/tests/wire_vectors.rs asserts the encoder's
bytes against contracts/wire-vectors.json, so the cross-SDK format is pinned
rather than agreed by habit.
use serde::{Deserialize, Serialize};
/// Field order is the wire order: the encoder keeps declaration order and never
/// sorts, because `auto:` idempotency keys hash the encoded bytes.
#[derive(Serialize, Deserialize)]
struct Order {
order_id: String,
amount_cents: i64,
}
#[flexiq::task(idempotent)]
fn charge(order: Order) -> flexiq::Outcome<()> {
println!("charging {}", order.order_id);
Ok(())
}Swapping those two fields changes the payload, and with it the derived auto:
key — a caller in another language that hashed the old order stops deduping
against you.
cbor2 ships as a required flexiq dependency — no extra install needed. The
default SmartSerializer can already read 0x02-tagged CBOR payloads (for
example one written by another FlexiQ SDK) without extra configuration; use
CborSerializer explicitly to write cross-SDK payloads.
cbor-x ships as a core dependency — no extra install required. Integers
beyond Number.MAX_SAFE_INTEGER round-trip as BigInt.
CborSerializer's com.fasterxml.jackson.dataformat:jackson-dataformat-cbor
dependency is compileOnly — add it to your build explicitly, same as
MsgpackSerializer.
Writing and reading are deliberately asymmetric here. The writer has exactly one
legal output, so it goes through the pinned encoder; the reader has to take
everything any producer may legally emit, so it is a full CBOR implementation.
In practice that means a job written elsewhere still decodes when it uses a
narrower float (1.5 at half precision reads back as 1.5), an integer past
2⁵³ (9007199254740993 keeps its last digit in an i64), a byte string, or an
indefinite-length head from a producer older than the definite-length rule.
What it will not do is guess. The leading byte has to be the 0x02 tag —
anything else is refused by number rather than attempted — and an empty payload
is reported as its own fault, because "no tag at all" and "a tag I do not know"
are different problems.
Producer and consumer of a task must agree on the same wire serializer — which means configuring it on both sides wherever the binding lets you choose. For cross-SDK tasks, prefer a single object/dict argument — it maps cleanly onto every language's handler-binding model.
HMAC-SHA256 integrity tag. Unlike EncryptedSerializer, it does not hide
the payload — it authenticates it. A worker refuses to deserialize bytes
that were not produced with the shared key, so an attacker who can write to
the queue's storage cannot smuggle in a forged payload.
import os
from flexiq import Queue, SignedSerializer, SmartSerializer
key = os.urandom(32) # share this across producers and workers
queue = Queue(serializer=SignedSerializer(SmartSerializer(), key))import { Queue, SignedSerializer } from "@byteveda/flexiq";
new Queue({
dbPath: "flexiq.db",
serializer: new SignedSerializer(process.env.FLEXIQ_SECRET!),
});import java.nio.charset.StandardCharsets;
import org.byteveda.flexiq.FlexiQ;
import org.byteveda.flexiq.serialization.JsonSerializer;
import org.byteveda.flexiq.serialization.SignedSerializer;
byte[] key = System.getenv("FLEXIQ_SIGNING_KEY").getBytes(StandardCharsets.UTF_8);
FlexiQ flexiq = FlexiQ.builder()
.sqlite("flexiq.db")
.serializer(new SignedSerializer(new JsonSerializer(), key))
.open();No signing layer, and no codec seam to hang one on: encode_call writes the
payload and the shell's ciborium reader decodes it, with nothing in between.
A task that must not trust its payload verifies it in its own body, where the
arguments have already been decoded into ordinary Rust values.
The default serializer can execute code on load (cloudpickle handles
lambdas and arbitrary objects). Without signing, anyone able to write to
the backing store can achieve remote code execution on every worker that
dequeues a crafted job. SignedSerializer closes that path. The key must
be at least 32 bytes of CSPRNG output and identical on producers and
workers.
The key is a plain secret string, hashed internally — producers and
workers just need to share the same string.
Be clear about what the decoder is and is not. It refuses a payload whose tag it
does not know, one that is not a two-element [args, kwargs] array, one
carrying keyword arguments, and one whose positional arguments do not fit the
handler's parameter types. Every one of those is a type check. None of them is
an authenticity check: a well-formed call with attacker-chosen arguments
deserializes cleanly, because it is indistinguishable from a legitimate one.
So the trust boundary is the database. If anything that can write to it is not already trusted, the verification belongs in the task body, on values you can name — an HMAC over a field you put there yourself, checked before the work starts.
AES-256-GCM encryption. Payloads stored in the database are opaque
ciphertext — only the key holder can read them. Provides confidentiality
and integrity, a superset of SignedSerializer.
import base64
import os
from flexiq import CloudpickleSerializer, EncryptedSerializer, Queue
key = base64.b64decode(os.environ["QUEUE_KEY"]) # 16, 24, or 32 raw bytes
queue = Queue(serializer=EncryptedSerializer(CloudpickleSerializer(), key))import { Queue, EncryptedSerializer } from "@byteveda/flexiq";
new Queue({
dbPath: "flexiq.db",
serializer: new EncryptedSerializer(process.env.FLEXIQ_SECRET!),
});import java.util.Base64;
import org.byteveda.flexiq.FlexiQ;
import org.byteveda.flexiq.serialization.JsonSerializer;
import org.byteveda.flexiq.serialization.EncryptedSerializer;
byte[] key = Base64.getDecoder().decode(System.getenv("FLEXIQ_ENC_KEY")); // 16, 24, or 32 bytes
FlexiQ flexiq = FlexiQ.builder()
.sqlite("flexiq.db")
.serializer(new EncryptedSerializer(new JsonSerializer(), key))
.open();Absent for the same reason — nothing wraps the payload on its way to storage. A field that must not be readable in the database has to arrive already encrypted: encrypt it before you build the call, decrypt it in the body, and the queue never sees anything but the bytes you handed it.
EncryptedSerializer always wraps an explicit inner serializer — there's no
implicit default. The key must be exactly 16, 24, or 32 bytes (AES-128/192/256),
base64-decoded if it came from a string. Generate one with:
python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"For both confidentiality and integrity, wrap one in the other:
from flexiq import EncryptedSerializer, Queue, SignedSerializer, SmartSerializer
inner = EncryptedSerializer(SmartSerializer(), key=enc_key)
queue = Queue(serializer=SignedSerializer(inner, sign_key))The key is derived from secret via SHA-256, so producers and workers only
need to share the string — not raw key bytes.
Encrypting inside the value has one consequence worth planning for: it changes
the payload bytes, and .idempotent() hashes exactly those bytes. A scheme with
a fresh nonce per call produces a different auto: key every time, so the same
logical job stops deduplicating against itself. Either make the ciphertext
deterministic for a given input, or name the identity yourself with
.unique_key(..), which wins over .idempotent() outright.
Note also that .metadata(..) and .notes(..) are stored as their own columns
rather than inside the payload, so nothing you put there is covered by any of
this — keep secrets out of both.
Where a binding exposes the seam, implement its Serializer interface with two
methods:
import msgpack
from flexiq import Queue, Serializer
class MyMsgpackSerializer:
def dumps(self, obj) -> bytes:
return msgpack.packb(obj)
def loads(self, data: bytes):
return msgpack.unpackb(data, raw=False)
queue = Queue(serializer=MyMsgpackSerializer())import { Queue } from "@byteveda/flexiq";
import type { Serializer } from "@byteveda/flexiq";
const mySerializer: Serializer = {
serialize: (value) => Buffer.from(JSON.stringify(value)),
deserialize: (bytes) => JSON.parse(Buffer.from(bytes).toString()),
};
new Queue({ dbPath: "flexiq.db", serializer: mySerializer });import org.byteveda.flexiq.serialization.Serializer;
public final class MySerializer implements Serializer {
@Override
public byte[] serialize(Object value) { /* ... */ }
@Override
public <T> T deserialize(byte[] bytes, Class<T> type) { /* ... */ }
}There is no Serializer trait to implement. serde is the seam in its place:
the crate's own Serialize sink turns each argument into core's wire value, so
a custom representation is a custom impl Serialize / Deserialize on your own
type.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Cents(i64);
#[flexiq::task]
fn charge(amount: Cents) -> flexiq::Outcome<()> {
println!("charging {} cents", amount.0);
Ok(())
}Two things the sink will not guess about. It reports
is_human_readable() == false, so a type that branches on that must take the
binary arm — which is why a Uuid round-trips instead of encoding as text and
failing when the job runs. And nothing widens silently: a u64 past i64::MAX,
or a map with non-text keys, is refused at enqueue rather than reshaped.
| Method | Signature | Description |
|---|---|---|
dumps | (obj: Any) -> bytes | Serialize an object to bytes |
loads | (data: bytes) -> Any | Deserialize bytes back to an object |
The serializer is used for both task arguments (the (args, kwargs) tuple)
and return values.
job.result() uses the queue's configured serializer for deserialization.
If you're using JsonSerializer or a custom serializer, results are
correctly deserialized with that serializer — not hardcoded cloudpickle.
The Type overload (deserialize(byte[] bytes, Type type)) is a default
method supporting generic payloads from a TypeReference; the base
implementation handles plain Class types and rejects generic ones —
override it in a generics-aware implementation, as JsonSerializer does.
Three more things the sink decides for you, none of them configurable:
BTreeMap<i64, _> is refused rather than
stringified — the envelope's maps are text-keyed, and coercing would put keys
on the wire the caller never wrote. A fieldless enum variant is a legal key,
because it already is a name.enqueue, not at call. name::call(..) mirrors
the task's signature and has nowhere to put a Result, so an unencodable
argument is carried on the TaskCall and surfaces from
FlexiQ::enqueue — naming the task that could not encode its arguments.| SmartSerializer | CloudpickleSerializer | JsonSerializer | MsgPackSerializer | CborSerializer | EncryptedSerializer | |
|---|---|---|---|---|---|---|
| Complex objects | Yes (cloudpickle fallback) | Yes | No | No | No (CBOR-representable types only) | Depends on inner serializer |
| Debugging | Binary payloads (opaque) | Binary payloads (opaque) | Human-readable JSON | Binary (opaque) | Binary (opaque) | Ciphertext (opaque) |
| Cross-language | Python only | Python only | Portable format, same-SDK payloads | Portable format, same-SDK payloads | Yes (cross-SDK wire format) | Python only (by default) |
| Performance | Best for plain data | Good | Good for simple types | Best | Good | Adds encryption overhead |
| Security | None | None | None | None | None | AES-256-GCM |
| Extra dependency | No | No | No | No | No | cryptography (flexiq[encryption]) |
| Default | Yes | No | No | No | No | No |
Rule of thumb: use the default SmartSerializer unless you have a
specific reason to switch — it gets you compact MessagePack payloads for
plain data with an automatic cloudpickle fallback for anything MessagePack
can't encode. Use CborSerializer when a task is produced or consumed by
another FlexiQ SDK — it's the only serializer here whose wire format is
part of the cross-SDK contract. Use EncryptedSerializer when tasks carry
sensitive data that must not be readable in the database. See
Architecture: Serialization for a shorter
overview and what gets serialized where.
| Serializer | When to use | Cross-SDK? |
|---|---|---|
JsonSerializer | The default — human-readable, debuggable payloads for Node↔Node work. Leave it unless you have a reason to switch. | No — portable format, same-SDK payloads |
MsgpackSerializer | Node↔Node work where you want compact binary payloads instead of JSON text. | No — portable format, same-SDK payloads |
CborSerializer | A task is produced or consumed by another FlexiQ SDK. The only serializer whose wire format (the 0x02 envelope tag) is part of the cross-SDK contract; round-trips big integers as BigInt. | Yes — cross-SDK wire format |
SignedSerializer(secret, inner?) | Payloads must be tamper-evident but not hidden — prefixes an HMAC-SHA256 tag and rejects mismatches. Authentication, not encryption. | Same-SDK by default (wraps JsonSerializer) |
EncryptedSerializer(secret, inner?) | Tasks carry sensitive data that must not be readable in the database — AES-256-GCM confidentiality and integrity. | Same-SDK by default (wraps JsonSerializer) |
Rule of thumb: keep the default JsonSerializer unless you have a specific
reason to switch. Use MsgpackSerializer for more compact Node↔Node payloads,
CborSerializer when a task crosses SDK boundaries, and EncryptedSerializer
when payloads must not be readable in storage.
| Serializer | When to use | Cross-SDK? |
|---|---|---|
JsonSerializer | The default — JSON via Jackson, accepts a custom ObjectMapper. Human-readable and debuggable for Java↔Java work. | No — portable format, same-SDK payloads |
MsgpackSerializer | Java↔Java work where you want compact binary payloads. Its jackson-dataformat-msgpack dependency is compileOnly — add it to your build. | No — portable format, same-SDK payloads |
CborSerializer | A task is produced or consumed by another FlexiQ SDK. The only serializer whose wire format (the 0x02 envelope tag) is part of the cross-SDK contract. jackson-dataformat-cbor is compileOnly. | Yes — cross-SDK wire format |
SignedSerializer(delegate, key) | Payloads must be tamper-evident but not hidden — prefixes an HMAC-SHA256 tag with constant-time verification. Authentication, not encryption. | Same-SDK by default (depends on delegate) |
EncryptedSerializer(delegate, key) | Tasks carry sensitive data that must not be readable in the database — AES-GCM confidentiality and integrity. Key must be 16, 24, or 32 bytes. | Same-SDK by default (depends on delegate) |
Rule of thumb: keep the default JsonSerializer unless you have a specific
reason to switch. Use MsgpackSerializer for more compact Java↔Java payloads,
CborSerializer when a task crosses SDK boundaries, and EncryptedSerializer
when payloads must not be readable in storage.
There is one and it is already chosen. The decisions left are about the types you send through it:
| The situation | What to do about it |
|---|---|
| A worker in another language runs this task | Stay inside CBOR's own shapes — integers, floats, text, byte strings, arrays, text-keyed maps. A type that encodes as bytes here (a Uuid does) arrives as a byte string there; send a String when the far side expects text. |
You want .idempotent() to dedupe against a producer elsewhere | Freeze your field order and never sort it. The key is auto: plus sha256(name || 0x00 || payload) truncated to 32 hex characters, so two swapped fields are two different jobs. |
| A value has no representation in the envelope | A u64 past i64::MAX, an out-of-range i128, and a non-text map key are all refused at enqueue rather than reshaped. Narrow the type; do not widen the wire. |
| The payload must not be readable in the database | Encrypt the field inside your own value — there is no layer to install, and see the note above about what that does to auto: keys. |
A PayloadCodec is a reversible byte-to-byte transform layered around a
serializer — compression, encryption, signing — applied after serialization
on the producer and reversed before deserialization on the worker. Wire
formats are part of the cross-SDK wire contract, so a codec-framed payload
decodes from any FlexiQ binding that ships codecs:
| Codec | Wire format | Description |
|---|---|---|
GzipCodec | standard gzip stream | Compression; decompression capped at 64 MiB by default (zip-bomb guard) |
AesGcmCodec(key) | [12-byte nonce][ciphertext || 16-byte GCM tag] | AES-GCM encryption, fresh nonce per payload. Key must be 16, 24, or 32 bytes |
HmacCodec(key) | [32-byte mac][body] | HMAC-SHA256 signing, constant-time verification. Key must not be empty |
A chain encodes in list order on the producer and decodes in reverse on the
worker — put GzipCodec before a signing or encryption codec so integrity
is verified before decompressing.
Apply a chain queue-wide — it wraps the queue serializer, so it covers every payload and result:
from flexiq import AesGcmCodec, GzipCodec, Queue
queue = Queue(codec=[GzipCodec(), AesGcmCodec(key)]) # compress, then encryptimport { Queue, GzipCodec, AesGcmCodec } from "@byteveda/flexiq";
new Queue({
dbPath: "flexiq.db",
codec: [new GzipCodec(), new AesGcmCodec(key)], // compress, then encrypt
});FlexiQ flexiq = FlexiQ.builder()
.sqlite("flexiq.db")
.codec(new GzipCodec(), new AesGcmCodec(key)) // compress, then encrypt
.open();No PayloadCodec either, so there is no queue-wide transform to install and no
chain order to get right. Compression and encryption live inside the value you
pass: hand the task bytes you already compressed or encrypted, and undo it in
the body.
Jobs written before a codec chain is turned on cannot be decoded through it — drain the queue before turning codecs on, or before changing the chain.
Register codecs under a name and opt individual tasks in — payload only, results still use the plain queue serializer:
from flexiq import AesGcmCodec, Queue
queue = Queue(codecs={"secret": AesGcmCodec(key)})
@queue.task(codecs=["secret"])
def export_report(report_id: int):
...import { Queue, AesGcmCodec } from "@byteveda/flexiq";
const queue = new Queue({
dbPath: "flexiq.db",
codecs: { secret: new AesGcmCodec(key) },
});
queue.task(
"exportReport",
(reportId: number) => {
// ...
},
{ codecs: ["secret"] },
);FlexiQ flexiq = FlexiQ.builder()
.sqlite("flexiq.db")
.codec("secret", new AesGcmCodec(key))
.open();
Task<Report> export = Task.of("export", Report.class).codecs("secret");No per-task codec names. #[flexiq::task] takes a fixed key set — name,
queue, priority, max_retries, timeout, idempotent, rate_limit,
cron and a handful more — and an unknown key is a compile error listing the
ones it accepts, so there is nowhere for a codec name to go.
Handlers annotated @Compressed / @Encrypted get the "compressed" /
"encrypted" codec names recorded on their generated task constants —
register codecs under those names on both producers and workers. The key is
supplied at runtime, never in the annotation.
Nor is there an annotation that could opt a body in. The per-call fields that do
exist beside the payload — .metadata(..) and .notes(..) — are separate
columns on the job row and never pass through the encoder, so they are neither
transformed nor protected by anything.
Where codecs exist, the same chain (or the same names) must be configured on producers and workers. Dedup keys for auto-derived idempotency hash the pre-codec payload, so a nondeterministic named codec (an AES-GCM nonce, for example) never breaks dedup — see Idempotency for the full precedence rules and the hash recipe.
The warning above is about per-task codecs=[...]. The queue-wide codec=
chain wraps the serializer itself, so a nondeterministic codec there (e.g.
AesGcmCodec) does make idempotent=True auto-keys nondeterministic
from one call to the next — pass an explicit idempotency_key instead when
a global codec chain is enabled.
With no codec in the way, "pre-codec payload" and "payload" are the same bytes —
the auto: key hashes what your Serialize impl produced, and nothing else
touches it. That makes the key as deterministic as your own types are, and no
more so: a field carrying a timestamp, a random nonce, or a HashMap whose
iteration order is not fixed will produce a fresh key on every call and defeat
.idempotent() quietly.
When a value cannot be made stable, name the identity instead — an explicit
.unique_key(..) takes precedence. Idempotency
has the precedence rules in full.