Option validation and the fail-closed server-precondition gate.
Two clearly separated responsibilities:
validate/1is pure — it normalises the user'sstart_linkoptions and refuses a mis-shaped or unsafe configuration before any socket is opened.check_preconditions/1issues oneCOM_QUERYon an already-authenticated socket and refuses to start unless the source's binlog is configured for lossless row-based CDC.
Two additive C2 helpers share this fail-closed posture: validate_snapshot/1 (pure —
normalises the :snapshot block, or {:ok, nil} when absent so the pure-C1 path is
byte-for-byte unchanged) and read_server_uuid/1 (the source-identity read reused across
BOTH connections, design Q-src / Ch8).
Server preconditions (ADR-0002)
check_preconditions/1 reads five global variables in a single query and refuses
with a DISTINCT reason per violation — degraded row decoding silently guesses
column identity, so the gate fails closed rather than proceed:
binlog_formatmust beROW— else:binlog_format_not_rowbinlog_row_imagemust beFULL— else:binlog_row_image_not_fullbinlog_row_metadatamust beFULL— else:binlog_row_metadata_not_fullbinlog_row_value_optionsmust be empty (""= full JSON, notPARTIAL_JSON) — else:binlog_row_value_options_not_emptygtid_modemust beON— else:gtid_mode_not_on
MySQL simple-query results are all text strings, so every value is compared as
text against the expected literal and never coerced to a typed term — an empty
binlog_row_value_options arrives as "", not nil or 0.
TLS verification posture (ADR-0002)
ssl defaults true. Peer verification is an explicit operator choice, never a
silent default: with TLS on, ssl_opts must carry EITHER a cacertfile/cacerts
OR an explicit verify:. Given neither, validate/1 fails closed with
:tls_verification_unspecified rather than let OTP's verify_peer default select
a posture nobody chose. This mirrors the guard Capstan.Protocol.Handshake applies
at connect time, moved to config time so a bad TLS configuration is refused before
any socket is opened.
Authenticated TLS against MySQL's auto-generated certificate. A cacertfile
alone drives verify: :verify_peer, but MySQL's auto-generated server certificate
is self-signed with CN …Auto_Generated_Server_Certificate and no SAN, so
verify_peer's hostname check fails against an address such as 127.0.0.1. An
operator taking the authenticated route must ALSO pass
server_name_indication: :disable in ssl_opts — the chain is verified, the
hostname is not (VERIFY_CA semantics). validate/1 does NOT inject this: that would
silently weaken every cacertfile user, so the operator supplies it deliberately.
Summary
Types
A value-free precondition-gate refusal (ADR-0002).
The normalised initial-snapshot configuration (C2), or nil when :snapshot is absent
(pure C1). tables is the snapshot set (a concrete {schema, table} list, or :all when
it defaults to an :all capture — resolved to a concrete list at bootstrap). store is the
durable snapshot store {module, start_link_options}. chunk_size bounds the brief lock's
hold + buffered memory.
The normalised configuration validate/1 returns on success.
A value-free option-validation refusal.
Functions
Reads the five server preconditions over socket and returns :ok iff all pass.
Reads @@server_uuid over an already-authenticated socket — the source-identity primitive
(design Q-src / Ch8).
Validates raw start_link options into a normalised config map, or returns a
value-free error.
Normalises the initial-snapshot :snapshot block, or {:ok, nil} when it is absent (pure C1).
Types
@type precondition_error() ::
:binlog_format_not_row
| :binlog_row_image_not_full
| :binlog_row_metadata_not_full
| :binlog_row_value_options_not_empty
| :gtid_mode_not_on
| :precondition_query_failed
A value-free precondition-gate refusal (ADR-0002).
@type snapshot_config() :: %{ tables: [{String.t(), String.t()}] | :all, store: {module(), keyword()}, chunk_size: pos_integer() }
The normalised initial-snapshot configuration (C2), or nil when :snapshot is absent
(pure C1). tables is the snapshot set (a concrete {schema, table} list, or :all when
it defaults to an :all capture — resolved to a concrete list at bootstrap). store is the
durable snapshot store {module, start_link_options}. chunk_size bounds the brief lock's
hold + buffered memory.
@type t() :: %{ connection: keyword(), server_id: pos_integer(), max_command_retries: non_neg_integer() }
The normalised configuration validate/1 returns on success.
@type validation_error() ::
:config_invalid | :server_id_required | :tls_verification_unspecified
A value-free option-validation refusal.
Functions
@spec check_preconditions(Capstan.Protocol.Packet.socket()) :: :ok | {:error, precondition_error() | {:query_error, non_neg_integer()} | {:transport, term()}}
Reads the five server preconditions over socket and returns :ok iff all pass.
Issues ONE COM_QUERY on the already-authenticated socket and compares each value
as text (ADR-0002). A wrong variable refuses with its distinct reason; a server or
transport error is surfaced fail-closed, never swallowed into a spurious :ok.
@spec read_server_uuid(Capstan.Protocol.Packet.socket()) :: {:ok, String.t()} | {:error, :server_uuid_read_failed}
Reads @@server_uuid over an already-authenticated socket — the source-identity primitive
(design Q-src / Ch8).
The bootstrap reads the STREAM connection's identity through this helper and pins the
Capstan.Query connection against it (:expected_server_uuid), so a query connection that
silently reconnects to a DIFFERENT replica mid-backfill is caught (:snapshot_source_mismatch)
— @@server_uuid is compared across BOTH connections. Returns {:ok, uuid} (a value-free
structural identity string) or the value-free {:error, :server_uuid_read_failed}; a
transport/query fault is scrubbed to the bare reason, never the raw term (Rule 1).
@spec validate(keyword()) :: {:ok, t()} | {:error, validation_error()}
Validates raw start_link options into a normalised config map, or returns a
value-free error.
Refuses: :server_id_required (missing or non-positive server_id),
:tls_verification_unspecified (ssl: true with no CA source and no explicit
verify:), and :config_invalid (any other missing or mis-shaped option). Defaults
applied: ssl true, ssl_opts [], auth_plugins [:caching_sha2_password],
password "", database nil, max_command_retries 5.
@spec validate_snapshot(keyword()) :: {:ok, snapshot_config() | nil} | {:error, :config_invalid}
Normalises the initial-snapshot :snapshot block, or {:ok, nil} when it is absent (pure C1).
An absent :snapshot key returns {:ok, nil} and touches nothing — the pure-C1 path is
byte-for-byte unchanged. A present block normalises to a snapshot_config():
tables— the snapshot set. Defaults to the capture allowlist (:tables, itself:allby default). When given it MUST be a non-empty list of{schema, table}binary tuples; the⊆ capturedcheck isCapstan.Pipeline.validate_snapshot_tables/2.store— REQUIRED, shaped likecheckpoint_store:[module: impl, options: keyword()]. A missing or mis-shaped store fails closed:config_invalid(a half-configured backfill would silently lose its resumability durability).chunk_size— a POSITIVE integer, default4096.
Any mis-shaped field fails closed with the value-free :config_invalid (the generic
mis-shaped-option refusal, as elsewhere in this module).