0021: Compatibility policy for 0.x¶
Status: accepted
Date: 2026-09-30T11:07:08-03:00
Amended: 2026-10-01T15:55:00-03:00, the public surface is the documentation site's reference (API, options, output schema), not the README
Amended: 2026-10-05T06:01:09-03:00, an older release keeps writing tables a newer one migrated, unless a migration sets mssql_cdc.min_version (amendment 2, ADR 0013)
Amended: 2026-10-05T12:35:09-03:00, every release keeps the state its wheel writes under tests/compat/<version>, and the current code must resume it (amendment 3)
Amended: 2026-10-05T12:49:29-03:00, the state contract covers the silver and reconcile schemas, the facts event values, the JSON of snapshot rows and a backfill wave's userMetadata, whose keys are only added; the public surface is what docs/reference/api.md lists (amendment 4)
Amended: 2026-10-05T20:51:10-03:00, the kept state includes a chunked snapshot left open, and the fake backend's files in it stay readable (amendment 3)
Amended: 2026-10-07T01:34:59-03:00, the API shape from 0.3.0: options and flags keyword-only, modes typed as Literals, results as TypedDicts whose keys are only added (amendment 5)
Amended: 2026-10-07T01:41:18-03:00, a micro-batch row's detail is a payload too, with the added key warnings (amendment 6, ADR 0023 Amendment 6)
Amended: 2026-10-07T09:14:31-03:00, the payloads and the source options get types, and a 'data_skipped' row's detail joins the listed payloads (amendment 7)
Amended: 2026-10-09T11:39:56-03:00, a wave's measured values in its userMetadata may be null where the platform refuses caching (amendment 8, ADR 0032)
Amended: 2026-10-10T18:02:10-03:00, a chunked snapshot's facts rows leave its key values to a plans table next to bronze: keys removed from the rows 0.7.0 writes only, readers take them from either (amendment 9, ADR 0028 Amendment 4)
Amended: 2026-10-13T18:45:19-03:00, a chunked snapshot opened after a capture instance switch adds switch_batch_id to its 'snapshot_open' payload, and its rows take keys by its S (amendment 10, ADR 0023 Amendment 7)
Amended: 2026-10-14T09:06:30-03:00, the 1.x policy: Semantic Versioning from 1.0, the state contract frozen but for additions, a deprecation before every removal (amendment 11)
Amended: 2026-10-15T09:39:58-03:00, the files in metricsPath are internal, not state: the facts table is the record (amendment 11)
Amended: 2026-10-15T09:49:09-03:00, dropping a Python, Spark, Delta or SQL Server version past its end of life is a minor, announced a release before; 1.0 keeps Python 3.10 (amendment 11)
Amended: 2026-10-15T09:50:28-03:00, a 'capture_instance_switched' row's detail is JSON from 1.0.0rc1, its keys state, its text for people (amendment 12, ADR 0023 Amendment 8)
Context¶
0.1.0 is the first release on PyPI. Semantic Versioning promises nothing before 1.0, but a stream leaves state behind that outlives any version: offsets in Spark checkpoints, checkpoint directories, and Delta tables in users' catalogs. A user who upgrades a patch or a minor and finds a checkpoint that no longer resumes, or a facts table the new version cannot write, has lost more than an API: they have to re-snapshot, or rebuild by hand. The Python API, on the other hand, is young and will change.
Decision¶
- Minor releases (0.x to 0.x+1) may break the public Python API. Every break is listed
under "Breaking" in
CHANGELOG.md, with what to change. - Patch releases (0.x.y to 0.x.y+1) are fixes only.
- The state contract never breaks without a migration path and an ADR, in any release:
- the offset JSON
{"lsn", "commit_ts"}(invariant 1, ADR 0002); - the checkpoint layout, including
<checkpoint>/_generations/<n>, the state file<checkpoint>/_mssql_cdc_generation.jsonand the<app_id>.g<n>app ids (ADR 0018); - the schemas of the facts, control, bronze, silver, reconcile report and (from 0.7.0)
snapshot plans tables: changed
only by appending migrations (ADR 0013). A
release keeps writing a table a newer one migrated, so the jobs that share it upgrade,
or roll back, one at a time; only a migration that sets
mssql_cdc.min_versionmakes older releases refuse it, and its release notes say so (amendment 2); -
the facts table's
eventvalues (NULL for a micro-batch,'bootstrap','resnapshot','snapshot_open','snapshot_plan','snapshot_chunk','schema_change','capture_instance_switched','data_skipped') and the keys of the JSON a later release reads back (amendment 4):detailof'snapshot_open':mode,kind,generation,lost_from_ts,lost_to_ts, and on a chunked onekeysandplan, and from 0.8.0 on one opened after a capture instance switchswitch_batch_id(amendment 10);detailof'snapshot_plan':snapshot,kind,keys,chunk_rows,chunks, and from 0.7.0chunk_count;detailof'snapshot_chunk':snapshot,chunk,wave,lo,hi,last;detailof a chunked snapshot's'bootstrap'or'resnapshot'row:snapshot,chunks,rows,last_lsn;- the userMetadata of a backfill wave's bronze commit:
backfill,wave,lsn,attempt,chunks, each chunk withchunk,lo,hi,last,rows,high_lsn,read_seconds,read_mb(ADR 0028); detailof a micro-batch row (eventNULL):warnings, added in 0.3.0 (amendment 6);detailof'data_skipped':from,to,certain, and from a task that found cleanup had run while it read,reason(ADR 0018, amendment 7);detailof'capture_instance_switched', from 1.0.0rc1:old,new,start_lsn,not_captured,text; before, the text alone (amendment 12);- from 0.7.0, the snapshot plans table next to bronze,
<target>_snapshot_plans, its columnssnapshot,eventanddetail,detailthe three chunked payloads above whole (amendment 9).
Keys are only added: never renamed, removed or given another meaning. A reader takes a key added after its payload first shipped with a default (
.get), so rows an older release wrote stay readable, as themodeof a'snapshot_open'row (missing or unknown reads as chunked) and a chunk'slast(missing reads as false) already are. The one removal is amendment 9's: the key values of a chunked snapshot's facts rows, from the rows 0.7.0 writes only, which the plans table holds instead.
A migration path means the new version reads the old state and carries it forward on its
own (as appended table migrations do), or the release notes give the exact steps.
* Every release entry in CHANGELOG.md has a "State compatibility" line: what it does to
offsets, checkpoint layout, table migrations and event payloads, with ADR links.
* Every release keeps the state it writes (amendment 3): tests/compat/generate.py, run
with the published wheel, writes a checkpoint with two generations and the bronze,
silver, facts and control tables to tests/compat/<version>, and from 0.2.0 on a
second stream's chunked snapshot left open after one wave (amendment 4's payloads); and
tests/compat/test_compat.py resumes each such directory with the current code, and
finishes that snapshot (RELEASING.md, step 6). Tests that rebuild an
old table from today's column lists follow any edit of those lists; bytes a release wrote
do not. 0.1.0's were written from its PyPI wheel after the fact. The directories also
hold the source the release's fake backend wrote (src/), which the test reads with
today's fake: the fake stays internal, but a change to its files must keep reading the
old ones.
* The public surface is what the documentation site's reference documents (amendment, ADR
0024): the objects docs/reference/api.md lists, the formats mssql_cdc and
mssql_cdc_snapshot, the options in docs/reference/options.md and the output schema in
docs/reference/output-schema.md (amendment 4: the page is the list; the copy kept here
fell behind it). CdcStream is public only as what stream() returns, not its name or
constructor. Everything else is internal and may change in any release, including names
exported from mssql_cdc that the API reference does not list, and the fake backend
with its fakePath option.
Consequences¶
- Upgrading within 0.x never costs a re-snapshot or a new checkpoint unless an ADR and the CHANGELOG say so, with the steps.
- Changing the state is expensive on purpose: reading the old format stays in the code.
- Documenting something in the reference pages makes it public; keep internals out of them or accept the contract. The guides and the README may show public things only.
- 1.0 will freeze the Python API as well; nothing here decides when (amendment 11 does).
Amendment 4: the snapshot payloads are state¶
A chunked snapshot stays open for days or weeks, so its 'snapshot_open' and
'snapshot_plan' rows are often written by one release and read by the next, and a wave
interrupted before its facts rows is rebuilt from its commit's userMetadata, maybe by a
newer release. backfill(), apply_changes and reconcile all read these payloads, yet the
contract listed only offsets, the checkpoint layout and the facts, control and bronze
schemas, although the silver and reconcile tables have migration kinds of their own.
- Every payload a release wrote has the shape listed above: 0.2.0rc1 introduced them and no
release has changed one since. An unreleased build put the kind in
modeand wrote nokind;backfill()now completes such an open, taking generation 0 as a bootstrap and a later one as a re-snapshot. - Considered: a version field in every payload. Additive keys read with defaults need none, and a version adds a second rule to keep. A change that cannot be additive gets a new key, or a new event value, and an ADR.
- Considered: one module that owns the payloads and derives a snapshot's state for every
reader. The readers' rules differ on purpose:
backfill()andapply_changestake any'bootstrap'or'resnapshot'row at or after S as complete (a newer full re-snapshot supersedes the chunks), whilereconcilechecks the chunks only against a completion row at S itself.
Amendment 5: the API shape from 0.3.0¶
0.2 grew to_delta to eleven parameters, all positional, each new one appended so that
positional calls kept working: to_delta(t, a, c, None, None, None, True) was a bootstrap,
and snapshot("bronze.x", True) a re-snapshot. Its siblings took options by keyword only,
but apply_changes took four strings in a row. Modes were plain str and results untyped
dicts, so a misspelt mode or key surfaced only at run time.
- Leading arguments stay positional: what a call is about, such as
to_delta(target, app_id, checkpoint, facts_table)(ADR 0014's form),apply_changes(spark, bronze, target),reconcile(spark, options, silver),advance(spark, control_table, table_name, end_offset),get_spark(app_name, master). Everything after them, options, flags and modes, is keyword-only. Breaking, in 0.3.0:to_deltaafterfacts_table;snapshot'sresnapshot;apply_changes'scapture_instanceandkeys;reconcile'skeys;granularityofadvance,track,candidateand theFinalizationListenerconstructor;timeoutofawait_allandFinalizationListener.join;end_offset_from_progress'ssource_index;delta_sink'smetrics_path;get_spark'sdelta. - Every mode parameter is a
Literalalias defined once inmssql_cdc.typesand exported frommssql_cdc:SnapshotMode,OnDataLoss,Isolation,Granularity, andBackfillStateforbackfill()'sstate. The run-time check stays: a wrong value raisesValueErrornaming the allowed ones. ALiteralnames the canonical spelling; where the check ignores case (isolation,granularity) it still does, so"readcommitted"runs but does not type-check.backfill()'sisolationandcandidate'sgranularitytook anystrbefore: a type-check break, listed under "Breaking". - Results are
TypedDicts, plain dicts at run time:Offset(snapshot(),seed(),end_offset_from_progress()),BackfillStatus,ApplyResult,ReconcileResult. Their keys are public API: a minor release may add one, and removing or renaming one is a break listed under "Breaking". Parameters that take an offset accept any mapping, so anOffsetand a parsed progress offset both fit. - Considered: a release with shims that warn (
FutureWarning) on the old positional forms, as pandas 2.0 did. A positional call fails at once with Python's ownTypeErrornaming the call, before anything runs, so the change cannot go unnoticed or half-run; a shim would be code kept for one release only. tests/typing_api.py, checked by mypy, pins the result types and keeps the old forms and wrong modes type errors.
Amendment 6: a micro-batch row's warnings¶
0.3.0 writes a micro-batch row's detail for the first time: JSON {"warnings": [...]}, the
warnings the reader logged on the driver, when there were any
(ADR 0023 Amendment 6). An added
payload, no change to a shipped one: rows written before, and batches without warnings,
keep detail NULL, so a query reads the key with a default. The key is state; the messages
in the list are text for people and may change in any release.
Amendment 7: types for the payloads and the options¶
0.4.0 types what the library writes and reads back. The payloads listed above were dicts built inline at each writer and parsed at each reader, so a writer that dropped or misspelt a key failed only when a later call, maybe a later release, read the row.
- Every listed payload is a
TypedDictinmssql_cdc.payloads, the row payloads also exported frommssql_cdcfor queries of the facts table:SnapshotOpenDetail,SnapshotPlanDetail,SnapshotChunkDetail,SnapshotCompletionDetail,DataSkippedDetail,BatchDetailandWaveMetadata(its chunksWaveChunk). Writers build them as these types, so mypy fails one that leaves out a key; readers annotate whatjson.loadsreturns and keep their.getdefaults.tests/test_payloads.pykeeps every key listed here in its type. The types describe the state, they do not change it: keys, formats and meanings are as before, and the rule stays that a key is only added. - A key a payload does not always have, a chunked open's
keysandplanor a task'sreason, or one added after its payload first shipped, is optional in its type. Not throughNotRequired, whichtypinghas from Python 3.11 while the package supports 3.10 without depending ontyping_extensions: the required keys sit in a private base class and the optional ones in atotal=Falsesubclass, which gives the same keys at run time on every version. - A 'data_skipped' row's
detail, written since 0.2.0 and documented with the facts table, joins the list: no release reads it back, but a query of the facts does. Itsreasonis text for people, like the warnings. SourceOptions, aTypedDictwithtotal=False, names every source option with astrvalue, as Spark passes them, andKNOWN_OPTIONSis derived from it, so the two cannot drift. The functions that take options (stream,reconcile,apply_changes,start_many) takeSourceOptions | Mapping[str, Any]: wider than 0.3'sdict, so every call that type-checked still does, and a dict annotated asSourceOptionsgets its names checked. Considered:Literalvalues forbackend,isolationLevelor the booleans; the reader takes them in any case, and booleans in several spellings, so aLiteralwould refuse values that run.- Considered: frozen dataclasses for the payloads. They are JSON that other releases and
users' queries read, so a dataclass would need converting both ways. Internal records
nothing persists are frozen dataclasses instead: the chunks
backfill()counts, and the chunks a 'snapshot_chunk' row announces toapply_changes. - A chunked 'snapshot_open' row now writes
keysandplanafterlost_to_tsrather than afterkind. A JSON object has no order, and every reader takes its keys by name. tests/typing_payloads.py, checked by mypy withtests/typing_api.py, pins the payloads' key types and keeps a misspelt option in aSourceOptionsa type error.
Amendment 8: a wave's counts may be null¶
Where the platform refuses to cache a wave (Databricks serverless), backfill() writes the
wave's commit while it reads it, so the commit's userMetadata cannot hold what the read
measures (ADR 0032). Not additive, so recorded here: the
keys stay, and a chunk's rows, high_lsn, read_seconds and read_mb are null in such a
commit (WaveChunk.rows becomes int | None; the other three were nullable already). The
wave's 'snapshot_chunk' facts rows hold the values. A reader takes null as not known:
backfill() counts the chunk's rows in bronze, as it does for a commit log cleanup dropped.
A release before 0.6.0 that finds such a commit (only after a crash between the wave's
append and its facts rows) writes NULL counts for its chunks.
Amendment 9: key values out of the facts¶
0.7.0 moves a chunked snapshot's key values out of the facts table, into a plans table next to bronze with bronze's access (ADR 0028 Amendment 4). Not additive, so recorded here:
- From the rows 0.7.0 writes for a snapshot it opens, the facts payloads drop
keysandplan('snapshot_open'),chunks('snapshot_plan') andloandhi('snapshot_chunk'); 'snapshot_plan' gainschunk_count. The plans table'sdetailholds each payload whole, under the same keys, so a query reads them there as it read them in the facts. Their types make those keys optional (SnapshotPlanDetail.chunks,SnapshotChunkDetail.loandhi; the open's were optional already). - Rows written before stay as they are and are read as before: a reader takes a payload
from the plans table, or from the facts row when it has its key values. A snapshot opened
before 0.7.0 is finished by 0.7.0 in the shape it started with, its key values in the
facts and nothing in a plans table, so that every job sharing those facts reads it,
0.6's included;
tests/compatfinishes each release's open snapshot so. - The other way round, a release before 0.7.0 on a snapshot 0.7.0 opened:
apply_changesfinds nokeyson the open and applies the waves without range deletes, the rebuild at completion deleting what they would have;backfill()andreconcilefail with aKeyError. Upgrade the jobs that run those with the stream; none of them writes a wrong row. No migration setsmssql_cdc.min_versionfor it: the facts table's schema is unchanged, and the stream of an older release keeps writing it.
Amendment 10: a snapshot after a switch¶
0.8.0 lets to_delta(snapshot="chunked", snapshot_on_switch=True) open a chunked snapshot
after the batch that first reads a newer capture instance
(ADR 0023 Amendment 7). Additive:
- Its 'snapshot_open' payload adds
switch_batch_id, thebatch_idof the switch's 'capture_instance_switched' row (SnapshotOpenDetail.switch_batch_id, optional);kindis'bootstrap', an existing value, and its completion a'bootstrap'row, so a consumer that filtersevent IN ('bootstrap', 'resnapshot')sees it, and so does an older release'sapply_changes. - Its 'snapshot_open' and completion rows take Delta keys by its S (
<app_id>#snapopen.<S>and<app_id>#snapdone.<S>, version 0), as the generation's own may be taken by its bootstrap or re-snapshot. A reader that finds noswitch_batch_id(every snapshot opened before 0.8.0) keeps the generation's keys. - Facts migration 14 and plans migration 1 change comments only; no
mssql_cdc.min_version. - The other way round: a release before 0.8.0 whose
backfill()finishes a snapshot 0.8.0 opened after a switch writes its completion under the generation's key, which Delta skips when the generation's own snapshot took it: the snapshot then never completes, silver keeps its verdict held and a full run raises (one mode while it is open). Runbackfill()with 0.8.0, which writes the row; nothing wrong is written meanwhile.
Amendment 11: the 1.x policy¶
1.0 freezes the public Python API and the state contract. 1.0.0rc1 comes first and runs a
real pipeline for a few weeks; 1.0.0 ships when nothing that run finds needs an API change,
and a change it does need goes into another release candidate, not after 1.0.0. From
1.0.0 on, this amendment replaces the Decision's first two bullets (what a minor and a
patch may change); the state contract, the "State compatibility" line and tests/compat
stay, with the additions below.
- Semantic Versioning, strictly:
- a patch (1.x.y) only fixes: it adds no public name, parameter, option, payload key or table column;
- a minor (1.x) adds: public names, parameters whose default keeps today's behaviour,
source options, keys of a result's
TypedDict, values of aLiteral(a mode,BackfillState), factseventvalues, payload keys, table columns by an appended migration; and it deprecates; - a major (2.0) breaks: it removes or renames any of those, changes a signature so that a call or a type check that passed fails, changes what a default does, or changes the state contract other than by adding to it.
A type-only break is a break (amendment 5): a narrower parameter type, a wider return
type, a member added to a protocol that a class implements without inheriting
(ADR 0030).
* What is public does not change: what the reference documents (amendment 4), the formats
mssql_cdc and mssql_cdc_snapshot, and CdcStream only as what stream() returns.
Every name mssql_cdc exports is in docs/reference/api.md (tests/test_api_shape.py).
The four it exported before 1.0 that the reference never listed, HAS_ADMISSION_CONTROL,
OPERATIONS, MssqlCdcDataSource and make_client, leave its __all__ and warn when
imported from it: internal, as this ADR already made them, they stay in
mssql_cdc.source and mssql_cdc.client, and leave mssql_cdc in 2.0.
FinalizationListener is public for join, last_error and failures; track()
creates it, and its constructor is not public: a listener it built never started its
worker.
* Readers handle what they do not know: in any minor, a result or a payload may gain keys,
and BackfillState and the facts event may gain values. A consumer that matches on
them keeps a default branch, and reads a key added after its payload first shipped with
a default (.get), as the library does.
* A deprecation before every removal. Nothing public is removed, renamed or made to behave
otherwise in 2.0 unless a 1.x minor deprecated it first: from then on it kept working and
warned, naming what replaces it, with a DeprecationWarning attributed to the caller
(stacklevel=2, so that a notebook or a test run shows it) for Python, and a WARNING
logged where the option is read for a source option. The changelog lists it under
"Deprecated", and the major that removes it under "Breaking", where every break is
listed with what to change. A deprecated name stays for the rest of 1.x.
* Supported versions follow their own end of life. Dropping support for a Python, Spark
(PySpark), Delta (delta-spark) or SQL Server version that has reached its end of life is
a minor release, not a major: the changelog announces it under "Deprecated" at least one
release before, and the minor that drops it says so under "Changed", with the new floor
(requires-python, the [spark] extra, or the SQL Server versions tested). A version
still supported upstream is dropped only in a major. 1.0 keeps Python 3.10.
* The state contract only grows. Besides the Decision's list and amendments 4 to 10, it
names what a later release reads back that the list left implicit:
- the keys of the generation state file <checkpoint>/_mssql_cdc_generation.json:
generation, snapshot_lsn, commit_ts, at, recovering, failed_at;
- the Delta txnAppIds and versions that make each write idempotent, since a release
that wrote under another would write a replayed row twice. The sink's app_id
(<app_id>, or <app_id>.g<n> in generation n) with the batch id, on bronze and, as
<sink app_id>#facts, on the facts table. From the stream's app_id, on the facts
table: #events and #snapshots (the generation), #snapopen.<S>, #snapplan.<S>
and #snapdone.<S> (0), #snapchunks.<S> (the wave); on the plans table
#snapopen.<S> and #snapplan.<S> (0), #snapchunks.<S> (the wave); on bronze, a
wave's #snap.<S> (the wave).
Table schemas change only by appended migrations that add columns or rewrite comments,
and no 1.x migration sets mssql_cdc.min_version: every 1.x release reads and writes
every 1.x table, so the jobs that share one upgrade, or roll back, one at a time. A change
that cannot be additive (a key renamed, a column dropped or retyped, a min_version) is a
2.0, with an ADR and a migration path; so is a change that fails tests/compat.
* Text for people is not state: the messages in a micro-batch row's warnings, a task's
reason, the text of an error, and the text of a 'capture_instance_switched' row's
detail (old -> new, then the columns no longer captured; the whole detail of a row
written before 1.0.0rc1, amendment 12) may be reworded in any release. A
'schema_change' row's detail is the DDL statement as SQL Server recorded it (its first
500 characters).
* The files in metricsPath are not state, nor public: the partitions' metrics, the
reader's events and a chunk's read metrics carry what the facts table records, from the
executors and the planning to the sink and backfill(), which remove them once folded.
Their names and contents may change in any release; the facts table is the public record.
A release that changes them still folds, or knowingly drops, what the one before left
there, as an upgrade may restart a stream with them: until the sink writes it, an event
file is the only trace of a skip past purged changes.
Considered:
- Keeping the 0.x rule, a minor may break the API, through 1.x. What 1.0 tells a user is that an upgrade within the major needs no change to their code; a break waits for 2.0, and the deprecation before it says what to change.
- A deprecation window in time (six months). Releases are small and irregular; a window in releases can be checked from the changelog alone, and a deprecated name stays for the rest of 1.x anyway.
- A major for every version dropped. Python, Spark and SQL Server end versions on their own calendars; tying the library's major to them would make 2.0 a matter of dates, or keep a dead runtime in the test matrix for the rest of 1.x. A platform past its end of life gets no fixes of its own either, and the release before the drop keeps working on it.
- Freezing the internal modules too (
client,events, the sink butdelta_sink, the fake). They change with every feature; only the reference is promised, and an internal name a user needs is listed there in a minor, which freezes it from then on.
Amendment 12: a switch's detail as JSON¶
Before 1.0.0rc1 a 'capture_instance_switched' row's detail was a line for people,
old -> new, then ; no longer captured, read as NULL: and the columns, which a query had
to parse for the instance names. Amendment 11 left it text that may be reworded, so nothing
could rely on it. 1.0.0rc1 writes it as JSON instead, typed CaptureInstanceSwitchedDetail
in mssql_cdc.payloads and exported from mssql_cdc with the other row payloads
(amendment 7) (ADR 0023
Amendment 8). Not additive, so recorded here:
- Its keys are state, only added from now on:
oldandnew, the capture instances;start_lsn, the newer one's start LSN S, which is also the row'smin_lsn;not_captured, the columns the query reads that the newer one does not capture (NULL from S on), empty when none; andtext, the line rows before held.textstays text for people (amendment 11). - Rows written before keep their text. A reader tells the two apart by the first character
(
{), or reads both throughevents.switch_of(detail, min_lsn), which gives the old text with the row'smin_lsnthe same payload; a query readscoalesce(get_json_object(detail, '$.text'), detail). The library itself never parsed it: the lab's check t9 and the documentation's queries did, and now read both. - Facts migration 15 rewrites the
detailcomment only; nomssql_cdc.min_version. A release before 1.0.0rc1 sharing the facts table keeps writing the text, which every reader above accepts.