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}wherereasonis 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:forstream_timeout_ms:) at the top level, inconnection:, insnapshot:, or in a store block — is refused:unknown_optionrather 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:(orcacerts:). MySQL's auto-generated cert is self-signed with no SAN, so also passserver_name_indication: :disable(VERIFY_CA — chain verified, hostname not); or - unauthenticated confidentiality only: pass
verify: :verify_noneexplicitly; 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_purgednon-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:
changesisEnumerable.t(), not a list. Never calllength/1/Enum.count/1on it. C1 delivers a list, but the type is the day-one contract: a later row makeschangesa 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-passStream).Dedup with
Capstan.Gtid.member?/2— never an ordinal comparison. A GTID set is legitimately non-contiguous (failover, purge, multi-source), socommit_lsn <= checkpoint-style comparison silently skips or re-applies. Skip a transaction whenCapstan.Gtid.member?(checkpoint_set, {uuid, gno})is true.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/1unchanged); 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 ONECapstan.Sink.handle_batch/2carrying 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()} # idempotentCapstan.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 lazyEnumerable.t()ofhandle_transaction/1.Capstan.Snapshot.Metacarries 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 sameserver_idis attached.:stream_stalled— a persistent network partition outlived the liveness reconnect budget.:command_retries_exhausted— pre-establish failures (connect/auth/query) exceededmax_command_retries.:invalid_liveness_config—stream_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 defaultxa: :refuse(the buffered rows are discarded, never delivered; ADR-0003). Withxa: :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 noNOT 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'sWEIGHT_STRINGbyte order DISAGREEING with itsORDER BYorder 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 briefLOCK TABLES … READfailed (missingLOCK TABLESprivilege, 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_uuiddiffers from the stream's (a reconnect to a different replica, at connect or on any query-conn reconnect).:snapshot_config_drifted— the configuredsnapshot.tablesno 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, orP0read fault beyond the budget.:snapshot_state_read_failed/:snapshot_state_write_failed— aSnapshotStorefault (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{}}— yourhandle_snapshot/2returned{: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.
| Event | Measurements | Metadata |
|---|---|---|
[:capstan, :connection, :established] | establish_ms | server_version, tls |
[:capstan, :connection, :stream_timeout] | — | reason |
[:capstan, :connection, :halt] | — | reason |
[:capstan, :transaction, :committed] | change_count, sink_ms | gtid |
[: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_seq | schema, 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 = ONbinlog_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).