capstan usage rules

Copy Markdown View Source

capstan streams committed MySQL row changes from the binary log to a sink in your supervision tree. This file is the consumer-facing contract; the permanent decision record is in docs/adr/.

Checkpoint modes: lib-owned (default — capstan persists the processed GTID set) and sink-owned (C1a — the sink persists its data and the position atomically together; see "Sink-owned checkpoint mode"). Explicit start positions remain a named follow-up (C1b).

Starting a pipeline

Configure each pipeline explicitly at Capstan.start_link/1 — there is no global mutable state.

Capstan.start_link(
  connection: [
    host: "replica.internal",
    port: 3306,
    username: "capstan",
    password: "…",
    database: "orders",
    ssl: true,                                  # default true (ADR-0002)
    ssl_opts: [cacertfile: "/etc/ssl/mysql-ca.pem", server_name_indication: :disable],
    auth_plugins: [:caching_sha2_password]      # default; name :mysql_native_password to allow it
  ],
  server_id: 1001,                              # replica identity; MUST be unique in the topology
  sink: MyApp.OrdersSink,                       # implements Capstan.Sink
  checkpoint_store: [module: MyApp.Store, options: []],   # lib-owned mode; see below
  start_position: :checkpoint,                  # :checkpoint (default) | %Position{} | :current
  tables: [{"orders", "line_items"}],           # or :all (default); filter applied before decode
  max_command_retries: 5,                       # default 5; pre-establish failures only
  xa: :track,                                   # default :refuse; see "XA transactions"
  batch: [max_transactions: 1000, flush_ms: 500],  # optional; see "Batching"
  max_prepared_transactions: 10_000,            # the :track prepared-pool bound
  reconnect_backoff: 1_000,                     # default; ms between reconnect attempts
  heartbeat_period_ms: 15_000,                  # default; server heartbeat on a quiet stream
  stream_timeout_ms: 60_000                     # default; MUST be > heartbeat_period_ms
)
  • Returns {:ok, supervisor_pid}, or {:error, reason} where reason is a value-free atom. A bad substrate or config is refused before any socket opens. An option key outside the documented surface — a misspelled key (stream_timeout: for stream_timeout_ms:) at the top level, in connection:, in snapshot:, or in a store block — is refused :unknown_option rather than silently defaulted.
  • Embed it with the child spec: {Capstan, connection: [...], server_id: ..., sink: ..., checkpoint_store: [...]}.

TLS (ADR-0002)

ssl defaults true. With TLS on you MUST make an explicit peer-verification choice, or start fails closed with :tls_verification_unspecified:

  • authenticated: pass cacertfile: (or cacerts:). MySQL's auto-generated cert is self-signed with no SAN, so also pass server_name_indication: :disable (VERIFY_CA — chain verified, hostname not); or
  • unauthenticated confidentiality only: pass verify: :verify_none explicitly; or
  • opt out entirely with ssl: false.

Streaming liveness

A silent half-open partition cannot hang the pipeline: capstan asks the server for a replication heartbeat every heartbeat_period_ms (default 15 000) of stream idleness, and a liveness timer declares the stream dead when no frame — event or heartbeat — arrives within stream_timeout_ms (default 60 000, tolerating three missed heartbeats). On fire it emits [:capstan, :connection, :stream_timeout], drops the connection, and reconnects; a persistent partition halts :stream_stalled. stream_timeout_ms MUST exceed heartbeat_period_ms, or the pipeline refuses to start (:invalid_liveness_config) — a window at or below the heartbeat interval would false-drop a healthy idle stream. All three values must be positive integers within the schedulable timer ceiling (2^32−1 ms ≈ 49.7 days); anything else is refused :config_invalid before any socket opens.

Start positions

start_position: accepts three forms. :checkpoint (default) resumes from the position authority — the checkpoint store (lib-owned) or your checkpoint/0 (sink-owned). An explicit %Capstan.Position{} resumes BOTH the dump and the dedup watermark from that position — you assert it is a safe resume point; transactions it covers are not re-delivered, everything after it streams. :current reads the server's live @@global.gtid_executed once, pre-dump, and resumes from it — "start from now" without pre-seeding.

First start — an empty checkpoint requests FULL retained history

A never-written store sends the binlog dump an empty GTID set, which means "everything the server still retains". Two consequences:

  • On a server that has already purged its earliest logs (@@global.gtid_purged non-empty), the server refuses the dump and the pipeline halts :data_gap.
  • On a server with complete retained history, the sink replays all of it from the top.

To start "from now" instead, seed the checkpoint store before first start with the server's current executed set — the store is the position authority, so a pre-seeded value is simply resumed from:

# 1. Read the server's current position with any MySQL client:
#      SELECT @@global.gtid_executed;
# 2. Write that string into your store before starting the pipeline:
:ok = MyApp.Store.write(store, gtid_executed)
# Capstan.start_link/1 now resumes from it — nothing before it is delivered.

Value-free start-up refusals

Capstan.start_link/1 returns {:error, reason} with one of: :server_id_required, :config_invalid, :tls_verification_unspecified, :invalid_liveness_config, :unknown_option, :invalid_sink, :sink_missing_handle_transaction, :sink_missing_checkpoint, :sink_missing_handle_schema_change, :checkpoint_store_required, :sink_owned_mode_unsupported, :start_position_override_unsupported, :start_position_current_unsupported. In snapshot mode also: :sink_missing_handle_snapshot, :snapshot_table_not_captured, and :config_invalid for a mis-shaped snapshot: block or a tables: :all snapshot set (see Initial snapshot). No row value, column value, or password ever appears in an error, log line, or telemetry payload (Rule 1).

The Capstan.Sink behaviour

Four callbacks; every one is @optional_callbacks — which are required depends on the mode. For C1's lib-owned mode you implement handle_transaction/1 and handle_schema_change/2.

@callback handle_transaction(Capstan.Transaction.t()) ::
            {:ok, Capstan.Position.t()} | {:error, term()}

@callback handle_schema_change(Capstan.SchemaChange.t(), Capstan.Position.t()) ::
            :ok | {:error, term()}

@callback checkpoint() :: {:ok, Capstan.Position.t() | nil} | {:error, term()}
# checkpoint/0 is consulted only in the (deferred) sink-owned mode.

Three load-bearing rules — each guards a silent-loss class the type signature alone does not:

  1. changes is Enumerable.t(), not a list. Never call length/1 / Enum.count/1 on it. C1 delivers a list, but the type is the day-one contract: a later row makes changes a lazy, single-pass, disk-backed enumerable valid only during the delivery call. Enumerate exactly once, streaming (Enum.reduce/3, Enum.each/2, for, or a single-pass Stream).

  2. Dedup with Capstan.Gtid.member?/2 — never an ordinal comparison. A GTID set is legitimately non-contiguous (failover, purge, multi-source), so commit_lsn <= checkpoint-style comparison silently skips or re-applies. Skip a transaction when Capstan.Gtid.member?(checkpoint_set, {uuid, gno}) is true.

  3. The checkpoint is a PROCESSED watermark, not a delivery log. It records every committed GTID processed — delivered and filtered. It does not mean "delivered".

handle_schema_change/2 receives only structured schema/table/kind — the raw DDL statement text is redacted before it reaches the sink (Rule 1). Return {:error, term()} from either delivery callback to halt the pipeline fail-closed without advancing the checkpoint.

Batching

batch: [max_transactions: n, flush_ms: ms, mode: :lib_owned | :sink_owned] batches the DURABLE side (absent — no batching; per-transaction delivery and checkpoint, byte-for-byte the C1 behavior).

  • :lib_owned (default): delivery stays per-transaction (handle_transaction/1 unchanged); only the durable CHECKPOINT write batches — one write of the batch's newest position at the bound (max_transactions) or the deadline (flush_ms), whichever first. The crash-replay window widens from one transaction to at most the un-flushed batch tail; dedup on restart already covers replay (set membership).
  • :sink_owned: the DELIVERY itself batches — your sink receives ONE Capstan.Sink.handle_batch/2 carrying the batch's transactions plus its final position, and must apply both atomically together (the batch effect-once contract; required callback in this mode, {:error, _} halts before the checkpoint side).

The batch closes IMMEDIATELY (never across) on any halt and on snapshot coordination.

XA transactions (ADR-0006)

Two-phase XA is opt-in: xa: :track (default :refuse halts :unsupported_transaction_shape at the prepare, the original posture). A one-phase XA COMMIT ... ONE PHASE is an ordinary atomic commit — it carries fully-committed rows with no rollback risk, so it is DELIVERED in both modes (the one deliberate deviation from the pre-XA unconditional type-38 halt; ADR-0006 §2). Under :track a prepared transaction's rows are pooled in memory (bounded by max_prepared_transactions, default 10 000 — exceeding halts :xa_prepared_pool_exhausted, never evicted) and delivered exactly once at the resolution: XA COMMIT delivers the pooled rows as one transaction; XA ROLLBACK delivers zero rows (both advance the watermark over the prepare AND resolution GTIDs in a single write — a prepare never checkpoints alone, the crash-safe held-out watermark). A prepare that predates the pipeline resolves row-lessly (capstan enumerates XA RECOVER at every connect). XA COMMIT/ROLLBACK with no known prepare halts :xa_commit_without_prepare / :xa_rollback_without_prepare. The XID bytes are application data (Rule 1): pooled under a sha256 key, never logged, emitted, or telemetered — errors correlate on GTIDs. One-phase XA commits immediately. The source account needs XA_RECOVER_ADMIN for the connect-time enumeration (the preflight checks it). During an initial snapshot, a prepared XA on a snapshot table blocks the brief per-chunk lock (its row locks serialize against LOCK TABLES … READ), so a chunk is never captured over an unresolved XA.

Sink-owned checkpoint mode

Omit checkpoint_store: and implement Capstan.Sink.checkpoint/0 — the sink IS the checkpoint. handle_transaction/1 must persist its data AND the delivered position atomically together and return the position; on (re)start capstan reads the resume position from checkpoint/0, delivers, and advances in-memory after each durable sink write. There is no store child and no separate checkpoint write — a crash between "delivery" and "checkpoint" is impossible by construction, which is what makes the mode effect-once (zero replays, zero skips across a kill/restart — the C1a acceptance marquee proves it live on an append-only ledger). An handle_transaction/1 that returns {:ok, position} without having durably persisted that position breaks the mode's contract silently — the durability is YOURS to provide.

Column-type breadth (C4a)

SET columns decode to MySQL's text form — the selected members comma-joined ("a,c"; an empty set is ""). GEOMETRY (and its typed aliases: POINT, LINESTRING, POLYGON, the MULTI_ and GEOMETRYCOLLECTION family) deliver the column's *raw spatial binary — the 4-byte SRID prefix plus WKB — verbatim; interpretation (WKT, parsers) is the sink's. A SET bitmap naming a member the schema does not declare is a metadata desync and halts {:unsupported_column_type, reason: :set_member_out_of_range}; pre-5.6 temporals remain refused (a supported 8.0+ source never emits them).

Memory shape. A transaction is buffered whole in pipeline memory between assembly and delivery, so peak memory scales with the source's largest single transaction; in snapshot mode add one fully-materialized chunk per table (bounded by chunk_size). Streaming/batched delivery of very large transactions is a named roadmap row (C3).

Checkpoint store (lib-owned mode)

Capstan.CheckpointStore persists exactly one value per pipeline: the processed gtid_set string (file/pos are diagnostic and never persisted). Implement two callbacks:

@callback read(store()) :: {:ok, String.t() | nil} | {:error, term()}   # nil = never written
@callback write(store(), gtid_set :: String.t()) :: :ok | {:error, term()}  # idempotent

Capstan.CheckpointStore.InMemory is a process-lifetime reference implementation (start_link/1, read/1, write/2) for tests and ephemeral pipelines — not durable across a restart. For production, implement the two callbacks over storage you already trust (your application database, a file written atomically) — write/2 must be durable and idempotent. Bridge Capstan.Position through read_position/2 / write_position/3, which apply the persist boundary so you never leak file/pos.

Wiring: checkpoint_store: [module: impl, options: keyword()] — the pipeline supervises the store, calling impl.start_link(options) (options defaults to []). A store fault at read or write is retried up to 5 times (not configurable in C1; :config_invalid is treated as permanent and halts immediately), then the pipeline halts fail-closed — {:checkpoint_read_failed, reason} / {:checkpoint_write_failed, reason}. This budget is separate from max_command_retries, which governs only pre-establish connection failures.

Initial snapshot (C2)

Backfill the rows that pre-exist the pipeline's start, woven into the running stream so there is no gap and no duplicate at the snapshot→stream handoff (ADR-0005). Absent the :snapshot block, a pipeline is pure C1 — this is purely additive.

Capstan.start_link(
  connection: [...], server_id: 1001, sink: MyApp.Sink,
  checkpoint_store: [module: MyApp.GtidStore],
  tables: [{"app", "orders"}],                 # capture allowlist (must be a concrete list to snapshot :all)
  snapshot: [
    tables: [{"app", "orders"}],               # ⊆ captured; DEFAULTS to the captured list
    store:  [module: MyApp.SnapshotStore],      # durable snapshot progress (SEPARATE from the gtid store)
    chunk_size: 4096                            # rows per chunk (bounds the brief lock's hold + memory)
  ]
)

Source privilege. The query account needs LOCK TABLES on the snapshot tables (in addition to SELECT) — capstan takes a brief per-chunk LOCK TABLES … READ to capture an exact GTID position (the read-only-replica-safe substitute for a written watermark; ADR-0005). No RELOAD / FTWRL. The preflight (scripts/capstan-preflight.sql) checks it.

The sink implements handle_snapshot/2 (optional in general; required in snapshot mode):

@callback handle_snapshot([Capstan.Change.t()], Capstan.Snapshot.Meta.t()) :: :ok | {:error, term()}
  • The first argument is a concrete list of %Capstan.Change{op: :snapshot} (record = the full row, old_record = nil) — a bounded, fully-materialized chunk, NOT the lazy Enumerable.t() of handle_transaction/1. Capstan.Snapshot.Meta carries only value-free structural identity (schema/table/chunk_seq/g/final_chunk?).
  • Apply each chunk row as an UPSERT keyed on its primary key. This is a HARD precondition: a snapshot row may be superseded by a later streamed change, and the bounded crash-window re-emit relies on upsert-by-PK to converge. Return {:error, term()} to halt fail-closed.

Guarantees. Strict-once in normal operation. The only duplicate window is a crash between the chunk's {:ok} and the durable pk_cursor persist — the one in-flight chunk re-emits, exactly C1's bounded posture; an upsert-by-PK sink converges. A DELETE landing in that same window is swept, not left as a phantom: a delivered_pk high-water (persisted before each emit) lets the cursor-gate forward a streamed delete of an already-delivered key on restart. Resumable mid-backfill: the per-table PK cursor lives in MyApp.SnapshotStore (implement read/1+write/2 over a %Capstan.Snapshot.State{}, same durable/idempotent contract as the checkpoint store), and the backfill resumes with WHERE pk > cursor — never a re-scan from zero. A store whose status: :complete never re-snapshots.

The snapshot table set is fixed once a durable state exists. Adding or removing a table in snapshot.tables after a :complete/mid-snapshot state is a config/state divergence and halts :snapshot_config_drifted — a newly-added table would otherwise be silently never backfilled. To re-backfill a changed set, drop the durable snapshot state (a fresh start re-introspects config).

Supported primary keys (order-faithful). Chunking pages by MySQL ORDER BY pk while the gate compares in Elixir, so the comparison must provably match MySQL's: integer (signed/unsigned, incl. BIGINT UNSIGNED), BINARY/VARBINARY, CHAR/VARCHAR (any charset, any collation — ADR-0012), and composites of those. A string PK's ordering rides its collation WEIGHT BYTES, computed by the server on both sides (the chunk read selects WEIGHT_STRING(pk); streamed raws resolve through a COLLATE-pinned CONVERT introducer) — Elixir never re-implements a collation. Two costs are inherent to the design: during a string-PK table's backfill window every streamed transaction carrying a NEW string key pays ≥1 weight-resolution round trip on the source (repeated keys hit a bounded cache), and composite row-value pagination is a full index scan per page (a pre-existing cost class for composites of ANY type). On resume the persisted cursor's weight half is recomputed from its raw half, so both sides of every comparison always come from the same server.

Still refused :snapshot_pk_unsupported_type, on measured grounds (ADR-0012): the TEXT family (a prefix PK pays a filesort per chunk page — Using filesort vs Using index on VARCHAR — superlinear backfill) and ENUM/SET (the column's ORDER BY is member-position order while any string-context weight is string order — two disagreeing order spaces). DECIMAL/DOUBLE/FLOAT/temporal PKs remain refused (Elixir term-order diverges from MySQL).

Zero-row tables complete loudly (C2c). A configured snapshot table with no pre-existing rows delivers exactly ONE snapshot beat — an empty final_chunk?: true chunk — so a sink gating per-table readiness on the final chunk never waits on an empty table. Tables that had rows deliver their final chunk as always.

A tables: :all snapshot — which arises when the capture allowlist is itself :all — RESOLVES to the server's scoped base tables: the information_schema.TABLES enumeration filtered to TABLE_TYPE = 'BASE TABLE' outside the system schemas (mysql, information_schema, performance_schema, sys), ordered and deterministic (C2b). Views, system schemas, and temporary tables are excluded by construction; a server exposing no scoped base tables refuses :snapshot_no_base_tables (a misconfiguration, never a silent empty backfill). The resolved list is what the durable snapshot state binds — on resume, the stored set is the authority for what :all meant (:all always reconciles).

Runtime halts (fail-closed)

Any condition that could otherwise lose or corrupt data silently halts the pipeline: the processes exit {:shutdown, {:halt, reason}} and — because every child is restart: :temporary — the pipeline is not restarted. Your application observes the halt via [:capstan, :connection, :halt] / [:capstan, :assembler, :halt] telemetry (metadata reason) and decides whether to restart, reprovision, or page. Every reason is value-free.

Server preconditions (checked at every connect; a violation halts without retrying): :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.

Compressed transactions are consumed. binlog_transaction_compression=ON sources stream normally: each TRANSACTION_PAYLOAD event is inflated by capstan's in-library pure-Elixir zstd decoder (RFC 8878; byte-exact conformance against reference-inflated frames) and its inner events fold through the assembler exactly as bare ones — the sink sees the transactions an uncompressed source would deliver. GTID/control/non-transactional events still arrive bare on such sources (a mix, by construction). A malformed payload — a non-ZSTD compression type, any zstd corruption signal, an uncompressed-size mismatch, a malformed inner header — halts with a value-free reason ({:payload_header, _} / {:payload_inflate, _} / {:payload_inner, _}); nothing is ever partially decoded. One named envelope: a payload whose declared or inflated size exceeds 1 GiB refuses :payload_too_large — checked on the DECLARATION before a byte is allocated, so a zip-bomb frame costs nothing. The 1 GiB constant (Capstan.Binlog.TransactionPayload, @max_inflated_bytes) is an operating envelope consistent with the rest of the system — the assembler buffers whole transactions in memory, so a >1 GiB single transaction is beyond capstan's working set regardless of the decoder. Sources with legitimately larger transactions need that constant revisited (and a streaming assembler design with it).

Connection halts:

  • :data_gap — the server purged binlogs the pipeline still needs (also the fresh-start case above). Re-seed or reprovision; resuming would silently skip transactions.
  • :source_identity_mismatch — the checkpoint carries GTIDs this server never executed; almost always a checkpoint pointed at the wrong server.
  • :server_id_conflict — another replica with the same server_id is attached.
  • :stream_stalled — a persistent network partition outlived the liveness reconnect budget.
  • :command_retries_exhausted — pre-establish failures (connect/auth/query) exceeded max_command_retries.
  • :invalid_liveness_configstream_timeout_ms <= heartbeat_period_ms.
  • :checksum_negotiation_failed, :unrecognized_dump_error, {:dump_failed, code}, :unexpected_stream_packet, :receiver_down — protocol-level refusals; the reason names the site.

Delivery halts (tagged tuples; telemetry reports the outer atom):

  • {:sink_error, reason} — your sink returned {:error, reason}; the checkpoint is held, so the transaction is re-delivered after a restart (at-least-once).
  • {:checkpoint_read_failed, reason} / {:checkpoint_write_failed, reason} — store fault after the retry budget.
  • {:assembler_error, reason} — a stream desync (:begin_without_gtid, :rows_without_transaction, …) or a row the pipeline refuses to decode: :unmapped_table_id, {:unsupported_column_type, detail} (a column outside the decoded breadth, e.g. pre-5.6 temporals), a compressed transaction payload, an unknown event type.
  • :unsupported_transaction_shape — an XA transaction under the default xa: :refuse (the buffered rows are discarded, never delivered; ADR-0003). With xa: :track (ADR-0006) XA no longer halts here — see "XA transactions" for the :xa_* halts (pool exhaustion, prepare-GTID mismatch, resolution without prepare).
  • {:event_parse_failed, reason} — CRC mismatch or truncated event.
  • {:event_decode_crashed, %Capstan.Error{}} / {:event_processing_crashed, %Capstan.Error{}} — an unexpected raise, captured value-free.

Snapshot halts (snapshot mode; each a distinct value-free reason per silent-loss condition):

  • :snapshot_table_no_primary_key — a snapshot table has no usable key (no PK and no NOT NULL-complete unique key).
  • :snapshot_pk_unsupported_type — a PK column outside the order-faithful allowlist (the TEXT family, ENUM/SET, DECIMAL/DOUBLE/temporal — each on a measured or provable ordering divergence; see ADR-0012).
  • :snapshot_pk_weight_failed — resolving a string PK's collation weights (ADR-0012) failed on the source beyond the shared retry budget; an ungated change would be a silent gap/dup, so the pipeline halts fail-closed instead.
  • :snapshot_collation_contract_violated — the bootstrap order-contract canary found the source's WEIGHT_STRING byte order DISAGREEING with its ORDER BY order over a fixed ASCII vector under the column's collation (ADR-0012). Every gate decision rests on that equality; a server that breaks it would mis-gate silently, so the snapshot refuses to open instead. A canary, not a proof — it covers the case/prefix/digit order classes the suite exercises, not every character a future server could reorder.
  • :snapshot_lock_unavailable — the brief LOCK TABLES … READ failed (missing LOCK TABLES privilege, or a lock-wait timeout) beyond the retry budget.
  • :snapshot_schema_drifted — a snapshot table's structure changed mid-backfill (a per-chunk fingerprint change, or an observed DDL on the table).
  • :snapshot_source_mismatch — the query connection's @@server_uuid differs from the stream's (a reconnect to a different replica, at connect or on any query-conn reconnect).
  • :snapshot_config_drifted — the configured snapshot.tables no longer match the durable %Capstan.Snapshot.State{}'s table set (a table added/removed after a :complete/mid-snapshot state). Halts rather than silently never backfilling the added table; drop the durable snapshot state to re-backfill a changed set.
  • :snapshot_chunk_read_failed / :snapshot_query_connect_failed / :snapshot_bootstrap_gtid_read_failed — a chunk read, query-connect, or P0 read fault beyond the budget.
  • :snapshot_state_read_failed / :snapshot_state_write_failed — a SnapshotStore fault (budgeted, then halts).
  • :snapshot_coordinator_down — the snapshot coordinator died silently (a stranded backfill is a gap, so it halts loud).
  • {:snapshot_sink_error, reason} / {:snapshot_processing_crashed, %Capstan.Error{}} — your handle_snapshot/2 returned {:error, _}, or the snapshot path raised (captured value-free).

Telemetry

Both channels are fail-closed value-free and BOTH are key-allowlisted (a numeric row value — a balance, an account number — is a non-negative number, so a type-only gate would let it ride): metadata keys and measurement keys each pass their own allowlist, measurement values must be non-negative numbers, and the refusals name keys only, never values.

EventMeasurementsMetadata
[:capstan, :connection, :established]establish_msserver_version, tls
[:capstan, :connection, :stream_timeout]reason
[:capstan, :connection, :halt]reason
[:capstan, :transaction, :committed]change_count, sink_msgtid
[:capstan, :transaction, :filtered]gtid
[:capstan, :transaction, :skipped]gtid, reason (:already_processed)
[:capstan, :schema_change, :received]schema, table, kind
[:capstan, :snapshot, :started]table_count
[:capstan, :snapshot, :chunk_completed]row_count, chunk_seqschema, table
[:capstan, :snapshot, :completed]table_count
[:capstan, :snapshot, :halt]reason
[:capstan, :assembler, :halt]reason

change_count is computed before delivery (never by consuming the sink's single-pass changes enumerable); sink_ms spans the sink call; establish_ms spans connect-to-streaming. Commit-to-delivery lag is deliberately NOT measured: the pipeline does not thread the binlog commit timestamp, and wall-clock skew would make a naive difference a lie.

The only log line emitted in normal operation is a Logger warning when authenticating with the deprecated mysql_native_password plugin.

Substrate requirements (ADR-0002)

capstan verifies these at connect and refuses to start (a distinct reason per violation) if any is wrong:

binlog_format                 = ROW
binlog_row_image              = FULL
binlog_row_metadata           = FULL
binlog_row_value_options      = ''   # PARTIAL_JSON is refused — it emits JSON diffs, not values
gtid_mode                     = ON

binlog_transaction_compression may be either OFF or ON: compression is source-unilateral (no consumer opt-out), and capstan CONSUMES compressed transactions — the in-library pure-Elixir zstd decoder inflates each TRANSACTION_PAYLOAD event (ADR-0011), so an ON source streams normally instead of being refused before the dump.

enforce_gtid_consistency = ON is recommended (implied by gtid_mode = ON) but is not separately checked. Multi-source replication is supported — a GTID set expresses multiple source UUIDs natively. The replication account authenticates via caching_sha2_password by default and needs REPLICATION SLAVE, REPLICATION CLIENT, and SELECT; LOCK TABLES for the snapshot path; and XA_RECOVER_ADMIN for xa: :track (the connect-time XA RECOVER enumeration).