Repository navigation
v0.155.0
MINOR — a HIGH stream-killer on every MySQL binlog sync with an indexed table, five silent schema-fidelity losses in the SQLite/D1 and Postgres readers that now carry or refuse, and the 2026-09-22 gap census's stated-capability corrections. On a MySQL or MariaDB binlog source, the first post-cold-start DDL boundary on ANY table carrying a secondary index halted the stream with a phantom unrecognized multi-shape combo delta … dropped-idx=1 refusal — the cold-start seed carried the table's indexes and the binlog boundary projection never did, so a real ADD COLUMN read as an add-column-plus-drop-index combo and the stream died before the post-ALTER row landed (GC-1; v0.73.0 through v0.154.0). Beside it, five things the readers silently mis-stated at exit 0 are now read from the catalog and carried on the IR, and this is why the release is a minor: ir.Column.Identity and ir.IndexColumn.Collation are new wire fields. A SQLite BIGINT PRIMARY KEY or WITHOUT ROWID key was assumed to be the rowid alias and landed as an auto-assigning identity the source never had (GC-4); a COLLATE NOCASE unique key landed on Postgres and MySQL as a case-sensitive unique that admits rows the source refuses (GC-5); a SQLite inline UNIQUE could not migrate to a SQLite target at all (GC-22); Postgres GENERATED ALWAYS AS IDENTITY was re-created BY DEFAULT with the factory INCREMENT 1 START 1 regardless of the source's sequence options (GC-3); and a PostgreSQL 18 VIRTUAL generated column was re-created STORED under a comment asserting Postgres had no VIRTUAL (GC-6). The rest: destructive prompts now refuse SLUICE-E-CONFIRMATION-REQUIRED on a non-terminal stdin instead of hanging or — for trigger teardown — exiting 0 having torn nothing down (GC-13); the ADR-0118 inert-flag WARN reaches every inert-marked flag under INERT-FLAG (GC-19); a libpq key/value DSN is no longer split on whitespace on the connection path (A0915-VF2-PGDSN-1); and thirteen operator docs, two help strings and four published error-code remedies were corrected against the code, each now held by a derived gate. Read Compatibility — several changes refuse or re-fingerprint where sluice used to proceed, and every one is stated there with its remedy.
Fixed
MySQL binlog: a schema-forward boundary on an indexed table no longer kills the stream (GC-1). On a mysql or mariadb source the cold-start seed is a full SchemaReader read and carried Table.Indexes, while the binlog boundary projection (projectTableIR) is {Schema, Name, Columns, PrimaryKey} by design and has never loaded information_schema.statistics. NormalizeForCDCComparison stripped indexes for the VStream flavors only (v0.99.48, F7c), so on the binlog flavors diffIndexes reported every seed index as dropped; combined with the real column delta that is a two-class combo, and ClassifyShape's multi-shape refusal returns from routeForwardBoundary BEFORE the preIsSeed guard that exists to absorb seed-versus-CDC phantoms. Reproduced red end to end: sync start on a table with INDEX ix_widgets_name, source ALTER TABLE … ADD COLUMN, stream halted with exactly the message above and the post-ALTER row never landed. The fix strips Indexes from the seed for every MySQL flavor — the honest match for what the projection carries, and it changes nothing about index-only DDL, which already produced no boundary on any MySQL source (the ADR-0049 signature is name plus ordered type); projecting secondary indexes onto the binlog boundary is ADR-0103 Phase 1(a), the path to forwarding index DDL later, and the strip is where it must be lifted when that lands. PrimaryKey handling is unchanged (the binlog projection carries it, Bug 89). The false premise was written in five homes and is corrected in each: the MySQL normalizer, its test, ADR-0091 §5b, the Postgres normalizer's comment about MySQL, and ir.CDCSchemaSnapshotNormalizer's doc. Pinned by TestNormalizeForCDCComparison_Binlog_SeedAgreesWithBoundaryProjection (the normalized seed versus projectTableIR of the SAME table classifies as no change, both flavors, roster derived from usesVStream over the flavor capabilities with a floor of two binlog flavors so MariaDB cannot escape), TestNormalizeForCDCComparison_Binlog_AddColumnOnIndexedTableIsAddColumn (the probe), and on real MySQL by TestStreamer_AddColumnForward_MySQL_IndexedTable_ForwardsALTER, whose independent expected value is the TARGET's own catalog still carrying ix_widgets_name — proof no phantom DropIndex was forwarded. Every pre-existing binlog schema-forward integration test creates an index-less table, which is why this was never caught. Sibling sweep: VStream fixed in v0.99.48; Postgres exempt (projectRelation carries no indexes and its normalizer already strips them); pgtrigger, sqlite-trigger, d1-trigger and sqlite exempt because none emits an ir.SchemaSnapshot; both consumers of the normalizer — the single-stream forward intercept and the Shape-A consolidation intercept — reach the fix. Affected: v0.73.0 (the boundary router and its multi-shape refusal) through v0.154.0, on MySQL binlog sources only. The span that is certain beyond doubt is v0.99.48 through v0.154.0, where the normalizer preserved indexes for binlog flavors on purpose; whether a binlog cold start fed its seed to the router in every v0.73.x–v0.99.47 build was not re-derived line by line. A halted stream is a LOUD failure — nothing was applied wrong, the target is behind rather than divergent. Not changed and flagged: renderDriftForRefusal still lists [index-dropped] unique index PRIMARY on a binlog seed-versus-boundary refusal because the drift renderer keys the PK by name and the projected PK has none; render-only.
SQLite/D1: the rowid alias is measured from the catalog, not assumed of every integer primary key, and the SQLite writer keys its spelling on the same fact (GC-4). The reader marked ANY single-column INTEGER-affinity primary key as Integer.AutoIncrement (the rowid alias), so a BIGINT PRIMARY KEY — a column SQLite stores NULL into — and every WITHOUT ROWID table's key landed on Postgres as GENERATED BY DEFAULT AS IDENTITY and on MySQL as AUTO_INCREMENT: an auto-assigning key the source never had, which SyncIdentitySequences then primed. SQLite's rule has four sub-clauses (declared type EXACTLY INTEGER, single column, rowid table, and not the column-constraint PRIMARY KEY DESC quirk); rather than re-derive them from CREATE TABLE text, the reader asks the catalog — SQLite materialises an origin pk entry in PRAGMA index_list exactly when the key is NOT the alias — and markRowidAutoIncrement keys on that, ground-truthed on modernc.org/sqlite over 70 cells before the predicate was written. The writer had the same class: soleIntegerPKColumn emitted the inline INTEGER PRIMARY KEY alias for any integer PK, so a BIGINT PRIMARY KEY source landed on a SQLite target that auto-assigns, and a NULL stored in such a source key would have been silently replaced by a number; it now spells a non-alias key as nonAliasIntegerPKType (BIGINT, INTEGER affinity, same read-back IR) with a table-level PRIMARY KEY clause. Pinned by TestRowidAlias_ReaderAndWriterMatchTheDriver (51 cells across six type spellings, four PK forms, rowid and WITHOUT ROWID, STRICT and AUTOINCREMENT, each MEASURED by inserting NULL on source and target; mutation-run both halves separately), TestEmitTableDef_NonAliasIntegerPKSpelling, TestD1SchemaReader_RowidAliasNeedsNoPKIndex, TestMigrate_PostgresIntegerPK_ToSQLite_AutoAssignMatchesSource (a PG identity key auto-assigns on the SQLite target; a plain BIGINT PK refuses NULL there, as PG does), and TestMigrate_SQLiteRowidAlias_ToPostgres / _ToMySQL / _ToSQLite reading pg_attribute.attidentity, information_schema EXTRA and a measured NULL insert respectively. Sibling sweep: the file reader and the D1 reader each fixed (shared predicate); the .sql dump, staged and trigger-CDC variants share those readers; the SQLite writer fixed; the D1 write side is not implemented. Affected: the reader since v0.99.141 (SQLite) and v0.99.143 (D1), the writer since v0.99.146, through v0.154.0. Silent at exit 0: every migrated row carried its explicit id, so the copy itself is intact; the harm is the target's column definition and, on a SQLite target, any NULL the source held in such a key.
SQLite/D1: an index column's collation and direction are read from index_xinfo and carried; a target that cannot enforce a foreign collation on a unique key refuses before the copy (GC-5). The reader used PRAGMA index_info, which exposes neither an entry's collation nor its direction, so email TEXT COLLATE NOCASE UNIQUE — a key the source enforces case-insensitively — landed on Postgres and MySQL as a BINARY unique that admits both a@x and A@X, at exit 0. ir.IndexColumn gains Collation and CollationDialect (following the Expression / ExpressionDialect precedent; an index column has no charset to key a dialect on, so two strings are the smallest honest carry), written with no key when empty so every existing manifest's fingerprint is byte-identical. readIndexColumns now reads PRAGMA index_xinfo, skips the auxiliary key=0 entries SQLite appends to every index, stamps each key entry's DESC and non-BINARY collation under the sqlite dialect, and the PK auto-index (code TEXT COLLATE NOCASE PRIMARY KEY) contributes its attributes to t.PrimaryKey; DESC on plain-column entries is recovered at the same time. The two-way policy on every writer, mirroring the prefix-length check: a foreign-dialect collation on a key that ENFORCES UNIQUENESS is part of the constraint and is refused naming the column and collation, at the emitter AND at Engine.PreflightIndexes (reached by migrate, restore, chain restore, sync cold start and add-table), while on a non-unique index it is dropped with an INDEX-COLLATION-DROPPED WARN (ordering and cost change, not which rows are legal). Per target: SQLite emits a sqlite collation verbatim; Postgres has no spelling for NOCASE (a case-insensitive key there is an ICU nondeterministic collation, citext or lower(col) — a different schema) and none for RTRIM; no MySQL collation folds ASCII case only (utf8mb4_0900_ai_ci folds accents too) — so every carried collation is foreign on both. ir/diff renders COLLATE <name> so a target that lost a key collation is drift, not silence. Pinned by TestIndexCollation_ReaderAndWriterMatchTheDriver (inline UNIQUE, composite table-level UNIQUE, NOCASE TEXT PK on rowid and WITHOUT ROWID, non-unique with DESC + RTRIM + NOCASE, BINARY controls never carried; source measured on the case-variant pair, reader carry exact, target measures the same outcome), TestIndexCollation_ForeignDialectRefusedOrWarned, TestForeignIndexCollations, agreement-matrix rows on all three engines, TestMarshalTable_IndexColumnCollationRoundTrip / TestUnmarshalTable_OldWireLeavesIndexColumnCollationEmpty, and TestMigrate_SQLiteIndexCollation_ToSQLite / _ToPostgres / _ToMySQL (SQLite target refuses the case-variant exactly where the source does; PG and MySQL refuse the three enforcing shapes BEFORE the table exists and land the non-unique shape with the WARN). Affected: v0.99.141 through v0.154.0 on a Postgres or MySQL target, silently. On a SQLite target the arm was unreachable until GC-22 in this same release (the migrate died in the index phase first). Not reached, stated: a per-column COLLATE on a column that is in no index is still unread, and the Postgres reader's own indcollation (GC-8) is unchanged.
SQLite/D1: a UNIQUE constraint's auto-index is carried as a constraint under a generated name, never under SQLite's reserved one (GC-22). A source table with an inline email TEXT UNIQUE or a table-level UNIQUE (a, b) could not migrate to a SQLite target: readIndexes skipped only origin pk, so the constraint's sqlite_autoindex_<t>_N was carried as a named index and the target's CREATE UNIQUE INDEX IF NOT EXISTS "sqlite_autoindex_t_1" was refused object name reserved for internal use — in the index phase, after every row had been written. Postgres accepted it under a table-prefixed rename and MySQL verbatim, so those directions worked by accident of the target's namespace rules. An origin u auto-index now becomes ir.Index{Unique, ConstraintBacked} under the name Postgres itself generates for the same inline UNIQUE — <table>_<col>[_<col>…]_key (uniqueConstraintIndex, shared by the file and D1 readers) — so a Postgres target re-emits ADD CONSTRAINT … UNIQUE and MySQL/SQLite a unique index; the name embeds the table, is legal everywhere, and is what lets ir/diff match the index (an unnamed index is invisible to drift). Two doors keep the class closed for shapes that can still carry the reserved name (a manifest written by an older binary; a foreign IR): refuseReservedIndexName refuses the sqlite_ prefix from emitCreateIndex and from Engine.PreflightIndexes, and the Postgres emitAddUniqueConstraint path — which emits a ConstraintBacked name verbatim and had no 63-byte check — now refuses through validatePGIdentifier rather than letting PG truncate into a collision. Pinned by TestSQLiteUniqueAutoIndex_ReservedNameIsRefusedByTheDriver (the premise, on the real driver), TestSchemaReader_UniqueConstraintAutoIndex, TestD1SchemaReader_UniqueConstraintAutoIndex, TestRefuseReservedIndexName, and TestMigrate_SQLiteInlineUnique_ToSQLite / _ToPostgres / _ToMySQL (each target refuses the duplicate email and the duplicate (tenant, handle); PG holds two pg_constraint contype u rows, MySQL two NON_UNIQUE=0 keys). Affected: v0.99.141 through v0.154.0; loud on a SQLite target, and the PG/MySQL directions completed correctly.
Postgres: GENERATED ALWAYS AS IDENTITY is no longer silently downgraded, and identity sequence options are carried (GC-3). information_schema.columns.identity_generation was never read and ddl_emit.go spelled every identity column GENERATED BY DEFAULT AS IDENTITY, so a source that refused application-supplied ids (SQLSTATE 428C9) landed on a target that accepted them, announced nowhere; and the sequence reader skipped every deptype='i' sequence, so INCREMENT BY 10 START WITH 3 CACHE 50 CYCLE — a sharded-id scheme — re-created as INCREMENT BY 1 START WITH 1, silently. BY DEFAULT during the copy stays deliberate (COPY and INSERT land the source's ids); the fix is fidelity and loudness after it. ir.Column.Identity (*IdentityOptions: Always, Start, Increment, MinValue, MaxValue, Cache, Cycle) rides schema_wire.go as identity with omitempty, decodes nil from an older manifest, and is EXCLUDED from the backup schema fingerprint — the reader sets it on every identity column, so folding it in would have minted a new epoch for nearly every Postgres chain (item 104's shape); the stated cost is that chain drift detection does not see an ALWAYS/BY DEFAULT flip or a re-optioned identity sequence. The reader captures identity-owned sequences into an owner-keyed map and REFUSES an identity column with no backing sequence rather than carrying zero-valued options that would render START WITH 0. The emitter's identityOptionsClause renders each option only where it differs from the ascending factory default for the emitted type, so an ordinary identity stays bare and a descending or re-optioned one round-trips exactly; a narrowing --type-override renders the source ceiling explicitly and CREATE TABLE refuses loudly rather than sluice re-capping the sequence silently. warnIdentityAlwaysDowngraded emits ONE WARN per run under IDENTITY-ALWAYS-DOWNGRADED naming every ALWAYS table.column, from table creation and from PreviewDDL. ir.IdentityGenerationRestorer (on the optional-dispatch roster) is implemented by the Postgres writer as ALTER TABLE … ALTER COLUMN … SET GENERATED ALWAYS per ALWAYS column, logged IDENTITY-ALWAYS-RESTORED, and the pipeline calls it from migrate ONLY, after the bulk copy, scoped to the tables migrate itself created this run, and NOT when the shard column is injected or --force-cold-start is set (the phase WARNs with the marker instead). Why migrate only, stated in code: the sync cold start, restore and chain restore each hand the target to a change applier that writes explicit ids — CDC rows, incremental links, a sync resumed from a restored position — which an ALWAYS column refuses with 428C9; they keep BY DEFAULT and the WARN is the operator's signal. MySQL and SQLite targets have no identity axis and WARN per column under IDENTITY-OPTIONS-NOT-CARRIED. The CDC normalizer zeroes Identity on the seed (pgoutput carries neither the generation kind nor the options) so no boundary reads every identity column as altered — the Bug 86 shape; ir/diff names Identity as a gap in columnFieldExempt rather than comparing it, and schema diff now logs that gap once (logIdentityGap) whenever both sides carry identity columns. Pinned by TestColumnJSON_IdentityRoundTrip, TestUnmarshalColumn_OldWireLeavesIdentityNil, TestSchemaHash_ExcludesIdentityOptions, TestColumnFromRow_IdentityGeneration, TestIdentityOptionsClause, TestWarnIdentityAlwaysDowngraded, the phase-order and exemption pins on the recording writer, and on real Postgres by TestMigrate_PGIdentityAlwaysRestoredAndOptionsCarried — the TARGET refuses an explicit id on both restored ALWAYS columns with 428C9 and accepts one on the BY DEFAULT column, the independent expected value, and the target's own pg_sequence row for the sharded identity equals the source's. Affected: every published release through v0.154.0 — the emitter has spelled every identity column BY DEFAULT since v0.1.0, and no reader ever carried identity sequence options (the standalone-sequence reader that arrived in v0.99.175 skipped identity-owned sequences by design). Postgres sources only; the harm is the target's column and sequence definition, never a copied value.
The pre-tag value-fidelity review of GC-3 found two defects the fix itself introduced, both measured, fixed and mutation-proven before landing; neither was ever published. First, silent loss: once identity options round-tripped faithfully, SyncIdentitySequences' unconditional setval(seq, MAX(col)) parked a DESCENDING identity ON an existing id — rows -1, -2, -3 → setval(-1) → the first INSERT on the target fails duplicate key (id)=(-2) after a migrate at exit 0 (before the change this failed LOUDLY at identity sync, because the factory ascending sequence refused setval(-1)). readTargetIdentitySequence now reads the TARGET's own pg_sequence row for the sequence pg_get_serial_sequence resolves — the independent value — and primes from MIN when the increment is negative, MAX otherwise; one fix reaches every caller (migrate, the sync cold start, the stopped-cold-start resume, restore, chain restore). sluice cutover (ADR-0062) had the same ascending-only walk in primeOneSequence and is now direction-aware from the same target read. Pinned by TestSyncIdentitySequences_DescendingPrimesFromMin, TestMigrate_PGIdentityDescendingPrimedFromMin and TestCutoverSequencePrimer_PG_DescendingIdentity. Second, a loud refusal on a working configuration: the new "identity column with no backing sequence" refusal fired on every PARTITION CHILD of an identity-bearing partitioned table on PG 17+ (measured on 18.6: information_schema reports the child's column ALWAYS, and the only deptype='i' row belongs to the root), inside ReadSchema and before the Bug 100 preflight whose remedy is --exclude-table=<parent>. SchemaReader.partitionRoots (a recursive pg_inherits walk to the relkind='p' root) now resolves a child's identity through its root — PG's own getIdentitySequence semantics — and the residual refusal names the shape and the remedy; pinned on a stock postgres:17 by TestMigrate_PG17IdentityPartitionChild_ResolvesThroughRootAndExcludeParentWorks. A third defect was caught by CI before the tag: the restore's ALTER TABLE was qualified by the IR's source schema, so under --target-schema it named "public"."orders" where the table lived under the override and failed 42P01. The fix is on the shared helper (qualifyTable now uses the writer's schema whenever an explicit target schema was set — TestQualifyTable_TargetSchemaWins), which also reaches the ADR-0054/0091 shape-delta and CHECK appliers on the sync schema-forward path, where the same qualification bug existed unexercised; TestMigrate_PG_TargetSchema_EnumColumn now asserts the restored column refuses an explicit id under the target schema.
Postgres: the generated-column storage class (STORED/VIRTUAL) is read from pg_attribute.attgenerated and carried, instead of asserting Postgres has no VIRTUAL (GC-6). PG 18 has VIRTUAL and spells the bare GENERATED ALWAYS AS (…) as VIRTUAL by default, so a PG 18 source's VIRTUAL column silently re-created STORED on every target. The premise was written in four homes — the reader, the emitter's WARN text, expr_translate.go's note suggesting VIRTUAL on PG 18 as a way around TO_CHAR's immutability (measured false: VIRTUAL is held to the same rule), and ADR-0016's copy — and a fifth, the change applier's projection comment; all corrected. columnFromRow sets GeneratedStored = attgenerated != 'v'; the writer probes server_version_num once at open and renders VIRTUAL on PG 18+ (generatedStorageClause), promoting to STORED below it with a WARN under GENERATED-VIRTUAL-PROMOTED-TO-STORED. Measured on real 18.6 and 16.15 and pinned: PG 18 also refuses VIRTUAL for an indexed column, an expression index over it, a UNIQUE, a FOREIGN KEY, a user-defined or domain column type, and a user-defined function or type in the expression — STORED accepts all of them. Two halves close that: virtualBlockedBy is the static predicate (index membership including expression entries by word-boundary identifier match, PK, UNIQUE, FK, and the Enum/Domain/ExtensionType/VerbatimType column types), and createTableRetryingVirtualPromotion is the loud fallback for the expression-level class the predicate cannot see — on a CREATE TABLE error matching pgVirtualGeneratedRefusals it retries that table ONCE with its VIRTUAL columns promoted (the IR is never mutated), WARNing with the marker and the server's own reason; every server string is bound to a measurement. CDC lane, measured: a VIRTUAL column is never on the pgoutput wire (even WITH (publish_generated_columns = stored) carries only the STORED one), NormalizeForCDCComparison already strips generated columns regardless of storage class, the applier never writes it and the target re-derives it — no lane change. Pinned by TestColumnFromRow_GeneratedStorageClass, TestEmitColumnDef_GeneratedStorageClassMatrix, TestVirtualBlockedBy, TestGeneratedColumns_StorageClass_PG18 and TestGeneratedColumns_VirtualPromotionRetry_PG18 on a pinned postgres:18 (round-trip checked against the TARGET's pg_attribute.attgenerated), TestGeneratedColumns_StorageClass_DefaultImage on the per-PR PG 16 and the weekly 17/18/latest matrix (the VIRTUAL-source cell asserts the server's own syntax refusal below 18 instead of skipping), and the cross-engine TestMigrate_MySQLToPostgres18_VirtualGeneratedShapes — a plain MySQL VIRTUAL column lands v, an expression-indexed one s with its index built, an ENUM-typed one s. Affected: the reader has asserted STORED since v0.3.1, through v0.154.0; it bites only on a PostgreSQL 18 source holding a VIRTUAL (or bare) generated column. Values are identical either way — the loss was the storage class, and the CDC lane was never affected. Stated residual: schema diff does not compare the storage class, as it never did.
Destructive prompts refuse on a non-terminal stdin, and trigger teardown no longer exits 0 with the engine still installed (GC-13). AGENTS.md told agents there are no prompts, ever, and that exit 0 means success; sluice trigger teardown without --yes prompted on stdin, and on a pipe, EOF or CI runner read the empty answer as "no", printed aborted and returned nil — exit 0 with every trigger and the change log still on the source. slot drop had been rebuilt in v0.99.189 to honour the claim and its comment named exactly this failure mode; trigger teardown was the un-enumerated sibling. One rule now applies to every prompt site: a destructive prompt may fire only when stdin is a real terminal and --yes is absent; anywhere else the command refuses SLUICE-E-CONFIRMATION-REQUIRED (exit 3, hint names --yes) BEFORE the prompt is printed and before either database is touched, and a decline at a terminal is errConfirmDeclined (exit 1), never nil. The door is refuseUnlessTerminal in cmd/sluice/confirm_door.go. Every os.Stdin read in cmd/sluice is enumerated with its disposition: both trigger teardown arms (postgres-trigger, and the sqlite-trigger/d1-trigger copy that had the same exit-0 abort) collapsed into one confirmTeardown; the three --reset-target-data prompts on migrate, sync start and sync from-backup run — which already exited 1 on decline but prompted on a pipe and would block forever on an open silent one — collapsed into confirmResetTargetData; and schema add-table's typed table name doored. slot drop and sync decommission keep their stricter refuse-always tier. Gated by TestStdinPromptRoster_EveryReadIsDoored (every call whose argument subtree contains the os.Stdin selector, resolved structurally, must be rostered AND preceded by the door in the same function body; floor equals the universe) and pinned by TestDestructivePromptDoor (32 subtests: piped stdin without --yes → coded refusal, prompt never printed, JSON envelope refused; piped with --yes → proceeds; terminal accept and decline) and TestDefaultStdinIsTerminal_PipeIsNot. The claim surfaces are made true in the same commit: AGENTS.md (root and embedded copy, held together by TestEmbeddedGuideMatchesRoot), the error-code row, four --yes help strings and three operator docs. Affected: trigger teardown's exit-0 no-op on postgres-trigger since v0.91.0 and on sqlite-trigger/d1-trigger since v0.99.149, through v0.154.0; the other four sites hung or prompted on a pipe but never reported false success.
The ADR-0118 inert-flag WARN reaches every flag whose --help says it is inert, on every engine it is inert on (GC-19). The finding 1(b) WARN covered three sync start flags by name and keyed on a mysql/planetscale/vitess allowlist, so --bulk-batch-size, both --index-build-*, the VStream FLOAT trio, --copy-table-parallelism, --reap-stale-backends, --control-keyspace and more were accepted and dropped silently — and even the covered three were silent on a trigger-CDC, mydumper or flat-file source. The WARN is now a data-driven registry (cmd/sluice/inert_flags.go, 37 rows across migrate, sync start, backup full, restore, the sync lifecycle commands, cutover, diagnose and trigger setup/teardown/prune) whose predicates are derived from the capability the pipeline dispatches on (ir.SnapshotImporterOpener for the ADR-0079 fast-lane knobs, Capabilities().CDC, ir.TargetConnectionBudgetProber, ir.TargetStaleBackendReaper, PostgresBackend, SchemaScope) rather than engine names; every WARN carries the grep-stable INERT-FLAG marker and names the flag, command, engine and reason. Detection stays by argv spelling, so an explicit default-valued pass warns and an unset flag never does. TestInertFlagWarnCoversEveryInertMarkedFlag walks the real kong model for inert-marker phrases (100 inert-marked pairs today) and fails the build on any that is neither registered nor exempt with a written reason; TestInertFlagWarnWiringRoster holds a call site per registered command; TestInertFlagPredicates_EngineMatrix pins every row against every registered engine; TestInertFlagWarnEmitsMarker and TestADR0118_InertParallelismWarnGate pin the live pass. The --max-target-connections help text no longer claims MySQL is the inert target (the MySQL engine implements ir.TargetConnectionBudgetProber; the inert targets are SQLite/D1 and the trigger-CDC targets). The sync run fleet path gets the one sibling it can have — a control-keyspace key against a non-MySQL target now WARNs via warnInertFleetControlKeyspace. Not registered by written reason: --copy-fanout-degree (runtime-decided) and --planetscale-org (recorded decision). Affected: the three covered flags since the v0.99.127 WARN; every other flag silently since it shipped. No flag is refused — this is a WARN.
A libpq key/value DSN is no longer split on whitespace on the connection path (A0915-VF2-PGDSN-1). password='s schema=x' dbname=real is two settings to libpq and pgx; a strings.Fields walk saw three tokens, recognised the middle one as a schema= setting, dropped it, and re-joined the survivors — handing the driver password='s dbname=real, a truncated credential with an unterminated quote. v0.154.0 fixed this grammar at the IDENTITY depth and named the connection-path defect as a follow-up; the mechanical sweep then found NINE sites where the filing named one: four rewriters that rebuilt the string (postgres.parseKVDSN, its pgtrigger copy reached from seven call sites — deleted in favour of delegation — withReplicationParam on the REPLICATION connection, and withSchemaKV on the multi-database path), one detector (withApplicationName, which a password containing that keyword made skip sluice's own stamp), one reader with a second defect of its own (serverKey compared keywords without lowercasing, so HOST=h fell through to the shared unparsed memo), two internal/pipeline readers exempt because the pipeline may not import an engine's grammar (worst case a wrong host label), and one URI parser exempt by construction. parseConnInfo returns each setting with the byte range it occupies in the ORIGINAL string and stripSettings deletes exactly those ranges, so spacing, quoting and escapes reach the driver untouched and a DSN carrying none of the stripped keywords passes through as the identity. Gated by TestConnInfoSplitRoster_EveryDSNWhitespaceSplitClassified, whose floor is on the matcher's reach (all 22 strings.Fields sites) rather than on findings. Affected: v0.1.0 through v0.154.0, Postgres and pgtrigger, only for a key/value DSN whose quoted value contains whitespace followed by a keyword sluice strips or scans for; it failed LOUDLY at connect. No behaviour change for any DSN that parses the same under both readings, which is every ordinary one.
Changed
Stated capabilities corrected against the code, each now held by a derived gate. verify --depth full was listed as shipped in the roadmap and offered in docs/verify-vs-vitess-vdiff.md; the enum is count,sample, the help already said full is planned, and both docs now say designed and NOT built (GC-12). --view-override was named as a workaround in the production-readiness known-limitations list and has never existed; the entry names the workarounds that do (--exclude-view, --skip-views, create the view by hand). TestDocsNameOnlyRealFlags now grades every --flag in the operator docs against kong per command, and its first run found four more phantoms (backup stream run --stream-id, migrate --schema-only=false --no-create-schema, sync start --resume, and a --compression-level a page argues against adding), all fixed. docs/throughput-tuning.md said --apply-batch-size defaults to 1 (it has been auto since ADR-0089) and called --analyze-after migrate-only (it is on both commands); every default on that page is now in the literal kong spelling and TestThroughputTuningDefaultsMatchKong holds it (GC-17). --allow-degraded-fks was offered as the --where FK escape on migrate AND sync in four homes; it is declared on migrate only, and every home now says so and what a filtered sync start sees instead (the raw SQLSTATE 23503). The VStream runbook told operators to alert on sluice_lag_seconds, sluice_seconds_since_last_event and sluice_streamer_state, none of which has ever been emitted — the live series are sluice_sync_lag_seconds (omitted after ~30 s idle) and sluice_seconds_since_last_apply; the section is rewritten around what exists and TestMetricsDocSync_EveryOperatorDocNamesOnlyLiveSeries grades every operator doc's sluice_* token against the union of the single-database and fleet scrapes (GC-16). The published SLUICE-E-BULKCOPY-TABLE-FAILED remedy kept "continue with --resume" for three raising commands that have no such flag, and three more rows paired a flag with the wrong command; TestDocRowRemediesNameOnlyFlagsTheirRaisingCommandsAccept grades every doc-row remedy's flags against the commands that raise the code (GC-18). docs/comparison.md promised the PG → MySQL direction "refuses loudly" on row-level security and untranslatable DOMAIN CHECKs; ADR-0063 decided WARN-once-and-drop (maybeWarnRLSDrop, pinned by TestRLSWarn_PGtoMySQL_DropsPoliciesAndWarnsOnce), and the sentence now says so and tells a multi-tenant operator to treat the WARN as a stop signal — whether the code should refuse by default is filed as GC-11's open code half (GC-11). sync start --include-database help claimed multi-database warm resume was unsupported (it shipped, pinned by TestMultiDatabaseWarmResumeSeed_WitnessesEveryNamespace and siblings); docs/operator/matview-refresh.md said an unknown --matview silently no-ops (validateMatviewFilter refuses before any REFRESH); and the --no-drain "PG-only" claim in four homes is now the honest statement — a Postgres source, or a MySQL-family source into a MySQL-family target via the ADR-0034 filter flip; any other pair refuses naming the drained flow (GC-14/15). ir.RowFilterSetter's doc said sync does not thread it (it has since ADR-0173 Phase 2, at three sites); the README's "zero production users" in five homes now says two outside production migrations are on the record (v0.111.1, v0.148.0) with no install base; the production-readiness status stamp is checked by TestProductionReadinessStatusStampIsCurrent; and the claim in three homes that CREATE/DROP INDEX and CHECK changes forward under --schema-changes is corrected — ADR-0091 §1d and the code agree they forward on no source (GC-20/21). The production-readiness honest list gains the object classes that are silently not carried (Postgres RULEs, grants, default privileges, ownership, extended statistics, security labels, text-search configs, tablespaces, reloptions; MySQL triggers, routines, events, partitioning, storage engine, CHECK … NOT ENFORCED, view DEFINER), each verified by grep before listing, and now states that Postgres schema reads need PostgreSQL 12 or newer (pgVersionSchemaReaderFloor, pinned by TestSchemaReaderFloorIsDocumented) — a floor the column query has carried since pg_collation.collisdeterministic entered it in v0.99.280, recorded now because no doc named one and a dead PG 11 gate on the attgenerated read implied otherwise.
Two ADRs, both Proposed and neither built. ADR-0188 designs the cutover ROLLBACK path — a reverse CDC stream anchored at the forward drain point, the per-engine anchor cost, and the line that decides it: same-engine pairs are bookkeeping and doors, cross-engine pairs are open-ended. DESIGN ONLY; the one thing it lands regardless is that docs/cutover.md and docs/use-cases.md no longer recommend a sluice migrate into the populated old source, which refuses SLUICE-E-COLDSTART-TARGET-NOT-EMPTY and whose two bypasses each defeat the purpose; the supported shape is a re-copy into an EMPTY standby armed before the flip. ADR-0189 designs the not-carried census — an optional reader surface for every object class a reader does not model, one preflight at every copy door, and a read-only sluice schema census — as the surface that would turn the census's whole silent-drop column into WARN-and-drop; build decision pending. The census itself is filed as GC-1 through GC-25 in docs/dev/audit-backlog.md; GC-25, found by the review of this release's SQLite branch and NOT fixed here, is a loud-late gap: a SQLite TEXT column under any secondary index on a MySQL target fails in the index phase after the whole copy with the raw Error 1170, because the inline-key check reaches CREATE TABLE only.
Test and CI infrastructure. Every slog capture in the test tree now uses internal/logcapture.Buffer, one mutex-guarded sink replacing NINE drifted copies under four names, after CI's -race shard caught a test reading a plain buffer while a go-mysql BinlogSyncer from an earlier subtest was still logging (slog.SetDefault is process-wide); gated by TestLogCaptureGuardRoster_NoTestPipesABareBufferIntoSlog, whose first draft passed over a planted defect because docsync's exprText rendered &buf as a type placeholder — resolved from the AST now, and the trap recorded in CLAUDE.md. v0.154.0's stated deferral — that d1-trigger's backup position capturer was pinned against the D1 mock only — is closed on live Cloudflare D1 by TestBackupChain_D1Trigger_PostFullRowsReachTheRestore, graded against D1's own COUNT and SUM after the post-full writes. The 2026-09-20 scheduled reds were triaged and were not product defects: the nekiverify suite discarded errors.As's boolean under a comment claiming a guarantee three paths break and panicked on a billed cluster; its teardown leak check went red precisely when nothing leaked (grep exits 1 on zero matches under pipefail); and the extended suites' reshard leg was the only one on a floating sluice-vitess:latest, now pinned to the digest that was green on 09-13.
Compatibility
Breaking changes: none for a working invocation, but read the six that follow — each refuses, re-fingerprints or spells DDL differently where the previous binary proceeded silently.
- Backup chains from a SQLite or D1 source whose tables carry an inline or table-level
UNIQUE, aDESCindex column, a non-BINARY index collation, or an integer primary key that is not the rowid alias (BIGINT PRIMARY KEY, any spelling other than exactlyINTEGER, or aWITHOUT ROWIDkey) read as a different schema on this binary. The schema fingerprint hashes index names, index-column collation and direction, andInteger.AutoIncrement, and this release's reader produces different values for each of those shapes (thesqlite_autoindex_*name becomes<table>_<cols>_key;DESCandNOCASEare now carried; the alias flag now matches the catalog). Two consequences, both from the code. An interruptedbackup fullresumed across the upgrade refuses underrefuseAnchoredResumeOnSchemaDriftwith "the source schema changed since the interrupted attempt (fingerprint … != recorded …)" — false but loud; pass--force-overwriteonce for a fresh full. An EXISTING chain extended on this binary records the difference as aSchemaDeltain the first post-upgrade incremental (the completion line reportsschema_deltas), that manifest's recorded schema becomes the new shape, and chain restore replays it — pinned byTestChainRestore_OldShapeSQLiteFull_NewShapeIncremental_ReplaysOnPGwith a hand-written old-shape full: three rows, a duplicate email refused, no index under the reserved name. Older links still verify because chain restore recomputes each manifest against its OWN recorded schema. No format version is raised and no epoch is minted; every manifest an older binary wrote reads exactly as before. - Resuming an interrupted backup of a PostgreSQL 18 source with VIRTUAL or bare generated columns across the upgrade refuses with the same schema-changed message. The fingerprint hashes
GeneratedStored, which this binary now reads as false for those columns where the previous one asserted true. Deliberately kept — excluding it would blind the guard to a real STORED/VIRTUAL retype on every cross-upgrade resume — and pinned with a hand-written pre-GC-6 manifest byTestRefuseAnchoredResumeOnSchemaDrift_PreGC6VirtualColumnRefusesLoudlyWithRemedy. Pass--force-overwriteonce for a fresh full. - A Postgres or MySQL → SQLite migration of a non-identity integer primary key now lands
BIGINTwith a table-levelPRIMARY KEYinstead of the aliasingINTEGER PRIMARY KEY. The target no longer auto-assigns on that key and refuses NULL, as the source did; a Postgres identity or MySQLAUTO_INCREMENTkey still lands as the alias and still auto-assigns. - A SQLite/D1 source with a
COLLATE NOCASE(or any non-BINARY) collation on a UNIQUE or PRIMARY KEY column now REFUSES on a Postgres or MySQL target before the copy, naming the column and collation. Previously it landed a case-sensitive unique silently. A SQLite target emits the collation verbatim. On a NON-unique index the collation is dropped with anINDEX-COLLATION-DROPPEDWARN and the migrate proceeds. The reservedsqlite_index-name prefix is refused at preflight on a SQLite target for any IR that still carries it (a manifest written by an older binary), and a generated<table>_<cols>_keylonger than 63 bytes is refused early on Postgres instead of truncated. - An
AGENTS.mdconsumer, CI job or piped invocation relying on "no prompts, ever" now getsSLUICE-E-CONFIRMATION-REQUIRED(exit 3) fromtrigger teardown,migrate/sync start/sync from-backup run --reset-target-dataandschema add-tablewhen--yesis absent and stdin is not a terminal — instead of a hang on an open stdin, a prompt on a pipe, or (fortrigger teardown) an exit-0 no-op. At a real terminal the prompts are unchanged, and a decline exits 1. Anything parsing the oldabortedline from a non-terminaltrigger teardownshould expect the coded refusal. - Manifests and schema-history entries written by this version carry two new optional fields —
identityon a column andCollation/CollationDialecton an index column — that older binaries ignore; a manifest without them decodes exactly as before. Restoring a v0.155.0 manifest on an older binary re-creates identity columns BY DEFAULT with factory options and drops the index collation, silently, which is precisely what that older binary did from a live source. Postgres schema reads require PostgreSQL 12 or newer — not a new requirement (the column query has referenced a PG 12 catalog column since v0.99.280, failing on 10/11 with SQLSTATE 42703), but stated indocs/production-readiness.mdfor the first time; the CDC lane's own floor is lower and does not help, because nothing runs before the schema read.
Other behaviour changes, by surface. On a MySQL or MariaDB binlog sync, a DDL boundary on a table with a secondary index now forwards instead of halting; index-only DDL still produces no boundary on any MySQL source, exactly as before. A MySQL VIRTUAL generated column migrated to PostgreSQL 18+ now lands VIRTUAL rather than STORED-with-WARN; below PG 18, and on PG 18+ for an indexed, keyed, user-typed or UDF-expression column, it is promoted to STORED with GENERATED-VIRTUAL-PROMOTED-TO-STORED and, for the expression-level class, one retry of that table's CREATE TABLE. A PG → PG migrate restores GENERATED ALWAYS on the tables it created (not on a pre-existing table, a shard-consolidation or --force-cold-start run, or on sync, restore and chain restore, which keep BY DEFAULT under the IDENTITY-ALWAYS-DOWNGRADED WARN), and re-creates identity sequences with the source's START/INCREMENT/MINVALUE/MAXVALUE/CACHE/CYCLE; a narrowing --type-override on an identity column whose source ceiling exceeds the new type now fails at CREATE TABLE rather than re-capping silently; a descending identity is primed from MIN on every path including cutover; and a partition child of an identity-bearing parent on PG 17+ reads its identity through the root. A Postgres identity column with no backing sequence in the catalog refuses at ReadSchema naming the shape and --exclude-table. MySQL and SQLite targets WARN IDENTITY-OPTIONS-NOT-CARRIED per identity column. Any explicitly passed flag that is inert for the resolved engine pair now WARNs INERT-FLAG; nothing is refused. A key/value Postgres DSN whose quoted value contains whitespace and a stripped keyword now connects. ir.SkippedTableRemedy-style prose changes: the --max-target-connections, --include-database, --yes (×4) and --schema-changes help strings are reworded, and the SLUICE-E-BULKCOPY-TABLE-FAILED, SLUICE-E-INDEX-STATEMENT-TIME-LIMIT, SLUICE-E-INDEX-DIRECT-DDL-DISABLED and SLUICE-E-PS-DEPLOY-REQUEST-INCOMPLETE remedies are per-command. Postgres sources on the CDC lane, the VStream lane, the trigger-CDC engines, mydumper and flatfile are byte-identical on every path this release touches except where named above.
Dependencies (Dependabot, no behaviour change observed). github.com/go-sql-driver/mysql 1.10.0 → 1.10.1; github.com/knadh/koanf/v2 2.3.5 → 2.3.6; golang.org/x/crypto 0.56.0 → 0.57.0 and golang.org/x/sync 0.22.0 → 0.23.0 (with x/sys 0.48.0 and x/text 0.42.0 indirect); the aws-sdk-go-v2 group — core 1.45.1 → 1.47.0, config 1.33.2 → 1.33.4, credentials 1.20.2 → 1.20.4, service/kms 1.58.0 → 1.60.0, service/s3 1.110.0 → 1.113.1 — plus their indirect modules.
Who needs this — action required
- Anyone running
syncfrom a MySQL or MariaDB binlog source whose stream halted withclassify shape on "db.t": unrecognized multi-shape combo delta (… dropped-idx=N …)after anALTER TABLEon an indexed table. Upgrade and re-runsync startwith the same--stream-id; the halt was a refusal before apply, so the target is behind, not divergent, and the resume picks up from the persisted position. If you worked around it by dropping the table's secondary indexes on the source or by--exclude-table, you can undo that. Nothing to re-verify. - Anyone who migrated a SQLite or D1 source with a
COLLATE NOCASE(or RTRIM) unique key to Postgres or MySQL on v0.99.141 through v0.154.0. The target's unique is case-sensitive and has been accepting rows the source refuses since the migrate; if the target has taken writes, query it for case-variant duplicates on each such column (GROUP BY lower(col) HAVING count(*) > 1) and decide which row wins before re-attaching anything. Going forward the migrate refuses that shape up front — build the case-insensitive key on the target yourself (an ICU nondeterministic collation,citextorlower(col)on Postgres; MySQL has no ASCII-only fold) and re-run against a pre-created table. - Anyone who migrated a SQLite or D1 source holding a
BIGINT PRIMARY KEY, a non-INTEGER-spelled integer key, or aWITHOUT ROWIDtable on v0.99.141 through v0.154.0. Every copied row carried its explicit id, so the data is intact; the target column, however, isGENERATED BY DEFAULT AS IDENTITY(Postgres),AUTO_INCREMENT(MySQL) or the aliasingINTEGER PRIMARY KEY(SQLite), with a primed sequence the source never had. If your application relies on the source's semantics (explicit ids only, or NULL stored in the key), alter the target column to drop the identity. On a SQLite target, a NULL the source held in such a key was silently replaced by a rowid — compareSELECT count(*) WHERE key IS NULLon both sides. - Anyone who migrated Postgres to Postgres with a
GENERATED ALWAYS AS IDENTITYcolumn or a non-default identity sequence (INCREMENT,START,MINVALUE/MAXVALUE,CACHE,CYCLE) — every release through v0.154.0. The target accepts application-supplied ids the source refused, and its identity sequences runINCREMENT 1from whereverSyncIdentitySequencesprimed them. For a sharded-id scheme that is the finding: any id the TARGET generated since the migrate came from the wrong step. Comparepg_sequenceon both sides for each identity column andALTER SEQUENCE/ALTER TABLE … ALTER COLUMN … SET GENERATED ALWAYSby hand; async,restoreor chain-restore target keeps BY DEFAULT on purpose and is not something to "fix". Going forward,migraterestores ALWAYS on the tables it created and carries the options; watch forIDENTITY-ALWAYS-DOWNGRADEDon the paths that do not. - Anyone with a PostgreSQL 18 source holding VIRTUAL or bare generated columns. Targets created before this release hold them as STORED; values are identical, and there is nothing to re-verify — re-create the column as VIRTUAL if you want the storage back. An interrupted
backup fullof such a source resumed on this binary refuses as schema-changed; pass--force-overwriteonce. - Anyone taking backups from a SQLite or D1 source with inline
UNIQUEs,DESCorNOCASEindex entries, or a non-alias integer key. An interrupted full resumed across the upgrade refuses as schema-changed (--force-overwriteonce); an existing chain extends normally and its first post-upgrade incremental records a schema delta that chain restore replays. Nothing to re-verify. - Anyone running
trigger teardownfrom a script, agent or CI job without--yes, on v0.91.0 (postgres-trigger) or v0.99.149 (sqlite-trigger/d1-trigger) through v0.154.0. Every such run exited 0 having torn nothing down; the triggers and change log are still on the source. Check withtrigger prune's preflight or the catalog and runtrigger teardown --yes. Going forward the same invocation refuses withSLUICE-E-CONFIRMATION-REQUIRED; add--yes. - Anyone connecting to Postgres with a key/value DSN whose quoted password or option contains whitespace. It failed at connect with an unterminated-quote or authentication error on every release through v0.154.0; it now connects. No action beyond retrying.
- Anyone whose runbooks alert on
sluice_lag_seconds,sluice_seconds_since_last_eventorsluice_streamer_state, pass--allow-degraded-fkstosync start, useverify --depth fullor--view-override, or runsync start --resume. None of those exists; the corrected docs name what does. Alerts on the phantom series have never fired. - Everyone else. Nothing to do; expect new
INERT-FLAGWARN lines only if you pass a flag your engine pair ignores.
Install: brew install sluicesync/tap/sluice · go install sluicesync.dev/sluice/cmd/sluice@v0.155.0 · Container: ghcr.io/sluicesync/sluice:0.155.0
Full changelog: https://github.com/sluicesync/sluice/blob/main/CHANGELOG.md