Error codes & exit codes
The stable SLUICE-E-* error codes and the process exit-code contract — a greppable branching surface for scripts, log pipelines, and agents driving the CLI.
sluice's error messages have always named the remedy in prose — "pass --zero-date=null", "use --resume". Prose is a poor branching surface for scripts, log pipelines, and AI agents driving the CLI, so every error class that carries an operator hint also carries a stable error code: a frozen SLUICE-E-<DOMAIN>-<SLUG> identifier machines can match exactly. The human-facing message is unchanged; the code and a concise remedy ride along as metadata.
A SLUICE-E-* code in sluice's output is stable and greppable — once shipped, the string is frozen (renaming or removing one is a breaking change), and it maps deterministically to an exit code (2 for a config error, 3 for a named refusal). The registry in internal/sluicecode is the single source of truth, and a unit test enforces that it matches this table in both directions. Codes are minted only for errors that already carry an operator hint — it is deliberately not a catalogue of every possible error.
Where the metadata surfaces: under the global --log-format json flag a terminal coded error emits one ERROR record with code, hint, and err attributes (text-format logging shows the same record in slog's text shape); the exit code lets a caller distinguish "sluice refused and named the remedy — retrying won't help" from a generic runtime failure without parsing anything.
Exit codes #
sluice historically exited 0 on success and 1 on everything else. The taxonomy below keeps those two meanings stable and carves two classes out of the generic-failure bucket, so nothing that checks != 0 changes behaviour.
| Exit code | Meaning |
|---|---|
0 | Success. For verify, diff, and sync-health: success and clean. |
1 | Generic runtime failure. For verify/diff/sync-health this is those commands' long-standing per-command meaning: the check ran and found a mismatch / drift / stale stream. |
2 | Config error: the --config file could not be loaded or parsed. (The read-side commands verify/diff/sync-health/metrics-watch have always used 2 more broadly for "the check could not run at all". For verify this includes a run that completed but could not verify one or more tables — a per-table count/sample error, or a source table missing on the target: an unverified table is not a pass, so those runs exit 2 rather than a misleading 0. Tables deliberately excluded via --include-table/--exclude-table or config filters stay exit-neutral.) |
3 | Named refusal: sluice declined to proceed (or to silently alter a value) and named the remedy — the refusal-class codes below. Retrying without acting on the hint fails identically. |
80 | Usage error: kong (the CLI parser) exits 80 on unknown flags/commands and missing required arguments, before any sluice code runs. sluice adopts this rather than remapping it. |
exit != 0 (including a systemd Restart=on-failure) are unaffected — every failure class is still non-zero. Scripts that check exit == 1 specifically should be updated: config errors and named refusals that previously exited 1 now exit 2 and 3.Error codes #
The class drives the exit code: a terminal refusal exits 3, a terminal runtime code exits 1 like any other failure — the code is in the log record either way.
| Code | Class | Meaning | Remedy |
|---|---|---|---|
SLUICE-E-CONNECT-REFUSED | runtime | The database host/port is unreachable from this machine. | Verify the DSN host/port and network reachability. |
SLUICE-E-CONNECT-AUTH-FAILED | runtime | The database rejected the DSN credentials. | Verify the DSN username and password. |
SLUICE-E-CONNECT-DATABASE-MISSING | runtime | The DSN names a database that does not exist on the server. | Verify the DSN database name. |
SLUICE-E-BULKCOPY-TARGET-TABLE-MISSING | runtime | Bulk-copy hit a missing target table — schema-apply failed or wrote into a different schema. | Check the schema-apply phase's output and the target schema/database the DSN points at. |
SLUICE-E-BULKCOPY-TABLE-FAILED | runtime | A table failed mid-bulk-copy; earlier tables have data but not their declared secondary indexes yet (the indexes phase runs after all tables finish copying). | Fix the offending table and continue with --resume, or skip it with --exclude-table=<name>. |
SLUICE-E-BULKCOPY-NO-PAGINATION-KEY | refusal | The d1 reader (the --source-driver d1 bulk copy, migrate --stage-local, and the d1-trigger cold start, which all share one paginator) refused a table it cannot keyset-paginate safely. Every page of a D1 read is bounded by the previous page's last key, so the key must be unique and orderable: a text-param-safe PRIMARY KEY, else the table's implicit rowid. A table has no such key when it is a WITHOUT ROWID table keyed only by a BLOB column (a text-param bound on a BLOB column never advances, so the read would loop), or — the shape audit LA-1 found — when its declared columns shadow every name SQLite resolves the implicit rowid through (rowid, _rowid_, oid; a user column of that name binds the COLUMN, not the rowid, so the old SELECT rowid probe succeeded on a PK-less table whose user column was named rowid and the keyset paginated on that non-unique column, silently dropping 500 of 2,500 rows at exit 0 on real D1). sluice now reads PRAGMA table_xinfo, keys on the first rowid name no column shadows, and refuses when there is none; the former LIMIT/OFFSET fallback that a failed probe used to route into is gone. The message names the table, what ruled out a PK keyset, and what ruled out the rowid. | Declare a non-BLOB PRIMARY KEY on the table (the keyset then uses it), or rename the column(s) shadowing rowid/_rowid_/oid so one of those names reaches the implicit rowid again, then re-run. For a WITHOUT ROWID table keyed only by a BLOB column, add a non-BLOB column to the key or migrate it through wrangler d1 export and sluice migrate --source-driver sqlite. |
SLUICE-E-BULKCOPY-ROW-COUNT-MISMATCH | refusal | The d1 reader brackets every table read with a server-side SELECT COUNT(*) — one before the first page, one after the last — and the two counts agreed with each other (a quiescent source) but disagreed with the number of rows pagination delivered. That is the reader losing or duplicating rows on this table's shape, the exact class audit LA-1 was (a shadowed rowid column skipping every row that shared a page-boundary value), and it is the only evidence in this lane that does not come from the reader under test; before it, a short copy logged bulk copy complete and exited 0. When the two counts disagree with EACH OTHER instead, writes landed during the read and the delivered count is not comparable to either, so sluice WARNs (naming both counts and the delivered total) that the copy is not a point-in-time snapshot rather than refusing — a live database would otherwise be unmigratable, and the keyset read is documented as non-snapshot under concurrent writes. Reaches the --source-driver d1 bulk copy, migrate --stage-local (the staged file would otherwise be silently short) and the d1-trigger cold start alike; the two counts are the same query sluice verify --depth count runs against a d1 source. | Do not trust this copy. Migrate the table through wrangler d1 export and sluice migrate --source-driver sqlite (the file reader streams a full scan and has no keyset), and report the table's DDL as a sluice bug — a quiescent-source mismatch is a reader defect, not an operator error. If the source is live, quiesce writes for the copy or expect the WARN form instead of this refusal. |
SLUICE-E-D1-TEXT-MANGLED | refusal | D1 returned more text bytes for a table than it stores, on a table whose row count did not move. D1 keeps invalid-UTF-8 TEXT intact on disk but replaces every invalid byte with U+FFFD in its /query JSON response, three bytes for one, SERVER-SIDE — so the mangled cell arrives as valid UTF-8 and nothing client-side can see it. The reader sums the byte length of the source's own text-storage cells in the same round trip as its closing COUNT(*) and compares; a disagreement means at least one cell was rewritten in transit, and copying it would persist the mangled value. Reaches the --source-driver d1 bulk copy AND migrate --stage-local, which share one paginator (the staged file would otherwise carry the mangled value into every later phase). The d1-trigger change-log poll does not share the bracket. The comparison is a table-wide sum, so it is not a per-cell guarantee: an inflating cell and a shrinking one could in principle cancel. On the migrate --stage-local path the refusal is scoped to the tables the run will actually READ (v0.141.0): staging copies the whole database by design and runs before the table filter is consulted, so refusing for every table meant one mangled table in a schema you had EXCLUDED failed the entire run with no flag able to reach it. A mangled table outside the run's scope now WARNS instead — it is staged as delivered and never copied to the target, and including it without repairing it would refuse. | The source is intact — hex(col) still returns the true bytes. Find the affected rows by comparing length(CAST(col AS BLOB)) against length(col) per text column, repair them at the source, or exclude the table. |
SLUICE-E-BULKCOPY-ROW-TOO-LARGE | refusal | The d1 reader (the --source-driver d1 bulk copy, migrate --stage-local, and the d1-trigger cold start, which share one paginator) met a single row that does not fit sluice's 8 MiB response cap even when requested as a page of one. D1 imposes no response-size limit of its own (audit LA-2 measured a 1,000-row page of 16 KiB BLOBs returned whole as 32.8 MB — the earlier 'D1 caps a response at ~1 MiB' premise was false), so the cap is sluice's memory bound and the reader keeps under it by sizing pages in bytes: the first page from a server-side LENGTH() probe of the widest candidate row, every later page from the previous page's measured bytes per row, halving and re-requesting a page that overflows anyway. Before this, the fixed 1,000-row page hit the cap on any table of rows wider than ~8 KB (a ~4 KB BLOB column doubles through hex()) and failed as unexpected end of JSON input, which named neither the cause nor a remedy. This refusal is what remains once the page is one row: that row's rendered values (BLOBs as hex, text as UTF-8 bytes, plus JSON framing) exceed the cap — on real D1 that takes several multi-megabyte columns in one row, since D1 refuses to store a value above ~4 MB and cannot hex() a BLOB above ~2 MiB at all (SQLITE_TOOBIG, surfaced as a plain read-page error). The message names the table, the row's key (its primary key, or its rowid), its estimated response size, and the cap; a smaller row that merely exceeds the page budget is read as its own page and never refused. The d1-trigger CHANGE-LOG POLL raises the same code from a second lane: a change row carries the full before and after images of one source row, so it runs roughly twice the width of the row itself, and a batch that overflows the cap is halved and re-requested against the same id > watermark (a strict prefix, so nothing is skipped) until it fits. The shrink is sticky for the life of the stream and resets on restart. The refusal is what remains at a batch of one, and there it is terminal for that stream rather than for one table: the poll cannot advance past the row. Before the poll had this, the batch size was fixed and is not an operator flag, so an over-cap change row killed the pump and every restart met the identical batch, with no remedy available at all. | Shrink or NULL the row's oversized values at the source and re-run, exclude the table (--exclude-table=<name>) and carry it separately, or migrate the table through wrangler d1 export and sluice migrate --source-driver sqlite — the file reader streams a full scan with no per-response cap (but note the export rounds integers above 2^53; see ADR-0132). |
SLUICE-E-SCHEMA-PERMISSION-DENIED | runtime | The target role lacks CREATE on the schema. | GRANT the privilege or use a different role. |
SLUICE-E-INDEX-STATEMENT-TIME-LIMIT | runtime | A post-copy index build hit PlanetScale's statement-time limit (MySQL errno 3024); the data is already copied. This surfaces only when the automatic deploy-request fallback (ADR-0148) is unarmed or unavailable — armed (planetscale target + --planetscale-org + service token + safe migrations ON), sluice builds the index via a deploy request instead. The arming exists on every mode that runs the deferred index build: migrate, restore, and sync start (--planetscale-org + service token; audit MED-A1), the fleet sync run per-sync specs (the planetscale-* YAML keys, service token env-first), and the sync from-backup broker (--planetscale-* flags) — the last two armed in the gap #12 close (2026-07-17). | --resume finishes just the indexes with no re-copy (grow the PlanetScale cluster first for a faster build), start fresh with --upfront-indexes, or arm the deploy-request fallback (--planetscale-org + PLANETSCALE_SERVICE_TOKEN_ID/_TOKEN, safe migrations ON). |
SLUICE-E-INDEX-DIRECT-DDL-DISABLED | runtime | PlanetScale safe-migrations is enabled on the target branch and blocked a direct ADD INDEX at the deferred index-build phase (errno 1105) — in practice a --resume/restore continuing an earlier copy, or a branch whose safe migrations was enabled mid-run. This surfaces only when the automatic deploy-request index-build fallback (ADR-0148) is unarmed — armed with --planetscale-org + a service token, sluice builds the indexes through a deploy request; the arming exists on migrate, restore, and sync start (audit MED-A1) and, since the gap #12 close (2026-07-17), the fleet sync run per-sync specs (planetscale-* YAML keys) and the sync from-backup broker (--planetscale-* flags) too. Note the fallback covers ONLY the index phase: a fresh migrate into a safe-migrations branch refuses earlier, at user-table creation, with SLUICE-E-PS-DIRECT-DDL-BLOCKED — unless the schema was pre-created via deploy requests, in which case migrate's pre-create gate skips the matching tables (ADR-0166) and the deploy-request path carries a fresh migrate end to end. | Give the command (migrate, restore, sync start, a fleet sync run spec's planetscale-* keys, or the sync from-backup broker's --planetscale-* flags) a PlanetScale service token (--planetscale-org + PLANETSCALE_SERVICE_TOKEN_ID/_TOKEN env) so the deploy-request fallback engages, or disable safe-migrations on the branch for the migration (the standalone sluice expand-contract and sluice deploy-ddl commands ship individual schema changes via deploy requests too). |
SLUICE-E-CONSTRAINT-STATEMENT-TIME-LIMIT | runtime | A post-copy foreign-key build hit PlanetScale's statement-time limit (MySQL errno 3024) at the constraints phase; the data is already copied and its indexes are already built. ADD FOREIGN KEY makes InnoDB validate every child row against the parent as it adds the constraint, so on a large table it is HEAVIER than an index build and cannot finish under the ~900 s wall (field-captured 2026-07-30 on a 153M-row table). An unfiltered migrate adds such a constraint metadata-only automatically (roadmap item 109 — the copied rows are FK-consistent by construction, so SET foreign_key_checks=0 makes the ADD an instant metadata change), so this code fires on a --where-filtered run (a row filter can legitimately orphan children, so the target-side validation is kept as the loud net) or a non-migrate path. Unlike the index wall (SLUICE-E-INDEX-STATEMENT-TIME-LIMIT) the constraints phase has NO deploy-request fallback (Vitess Online DDL refuses FK-participating tables), so --resume re-runs the identical validating ADD and re-hits the same deterministic wall. | Re-run with --skip-foreign-keys to complete the migration without the foreign keys — each skipped FK's referencing columns are still backed by a synthesized index, so you can add the constraints out-of-band afterward (ALTER TABLE … ADD FOREIGN KEY …, ideally with foreign_key_checks=0 since the copied rows already satisfy them). If a --where filter is orphaning children, fix the filter so parent and child stay referentially consistent. Alternatively grow the PlanetScale cluster so the child-row validation finishes under the statement-time limit. |
SLUICE-E-FK-SOURCE-ORPHAN | refusal | The roadmap-item-109 metadata-only foreign-key wall recovery added a wall-blocked ADD FOREIGN KEY under SET foreign_key_checks=0 (skipping InnoDB's O(rows) child-row validation), then PROVED the child rows actually satisfy the constraint with a bounded chunked orphan scan — each query clipped to a PK range so it cannot itself hit the ~900 s statement wall. This code fires when that scan found a child row whose (non-NULL) foreign-key value has no matching parent: the source was not FK-consistent by construction after all. Rather than leave a silently-violated constraint (which --allow-degraded-fks is deliberately withheld from doing on MySQL), sluice DROPS the foreign key it just added and refuses the run — recovering the loud orphan signal a validating ADD would have produced, but which the wall killed before it could. The message names the child table, the foreign key, and an example violating child key. | Clean the orphaned child rows on the source (the named child table has rows whose foreign-key value points at a parent row that does not exist), then re-run. Or re-run with --skip-foreign-keys to complete the migration without the constraint (its referencing columns stay indexed, so you can add it out-of-band once the data is clean). |
SLUICE-E-CDC-REPLICATION-PERMISSION | runtime | The connecting role lacks the REPLICATION attribute. | ALTER ROLE x REPLICATION; see postgres-source-prep. |
SLUICE-E-CDC-POOLER-ENDPOINT | runtime | The replication-slot creation command was rejected by the source as a SQL syntax error (SQLSTATE 42601 on/near CREATE_REPLICATION_SLOT) — the signature of a connection pooler (Supabase Supavisor, transaction-mode pgbouncer) in front of the database: the pooler stripped the replication=database startup parameter, so the replication-protocol command reached a normal backend as plain SQL. Most transaction/statement-mode poolers strip the parameter; some session-mode/modern-pgbouncer setups (pgbouncer ≥ 1.24 — e.g. Vultr's managed pools) forward replication end-to-end, and those never produce this refusal — it fires only on the observed strip, never on a host pattern. | Point --source at the DIRECT database endpoint (Supabase: the db.<ref>.supabase.co host, not *.pooler.supabase.com; note the direct endpoint is IPv6-only on Supabase — from an IPv4-only network the IPv4 add-on is required). See managed-services. |
SLUICE-E-CDC-CHANGELOG-ID-REUSE | refusal | The sqlite-trigger / d1-trigger change log can hand a future change an id at or below the stream's resume watermark, which would make that change permanently invisible to the id > watermark poll — captured, never replayed, with the position still advancing. Two states reach it, and both are graded at each CDC door (stream start and cold-start snapshot). Not AUTOINCREMENT: sluice trigger setup emits CREATE TABLE IF NOT EXISTS, so a table already named sluice_change_log is accepted as-is; if its id column is plain INTEGER PRIMARY KEY, SQLite reuses rowids and the first capture after the auto-prune drains the log is allocated id 1. Lowered sqlite_sequence: that table is ordinary writable state, and SQLite allocates max(MAX(id), sqlite_sequence.seq) + 1 — so lowering seq is inert while rows remain and becomes an id re-issue as soon as the prune empties the log. This is the SQLite counterpart of the Postgres change-log sequence preflight (verifyChangeLogSequence); there is no pg_sequence row here, so the same hazard is spelled in sqlite_master and sqlite_sequence instead. | Not AUTOINCREMENT: drop sluice_change_log once the stream is caught up (its rows are already-applied history) and re-run sluice trigger setup --dsn=... so the id column is declared INTEGER PRIMARY KEY AUTOINCREMENT. Lowered sequence: UPDATE sqlite_sequence SET seq = <the stream's watermark or higher> WHERE name = 'sluice_change_log', then restart the stream — or cold-start the sync again if the watermark is not trustworthy. |
SLUICE-E-CDC-GENERATED-PRIMARY-KEY | refusal | A CDC source table's row identity — its PRIMARY KEY, or on Postgres whatever REPLICA IDENTITY resolves to — includes a generated column that the change stream does not carry. The reason differs by engine and the consequence does not. On MySQL the binlog carries the column and sluice's decoder deliberately drops it, because the target's own GENERATED clause recomputes the value — right for the INSERT/SET side, fatal for the WHERE side. On Postgres the value never arrives at all: pgoutput does not publish generated columns before PostgreSQL 18, so pg_publication_tables.attnames omits the key and the relation message sluice receives does not mention it. Either way the before-image narrows to an identity it does not have, and the apply renders one of three wrong things — an empty WHERE (a hard SQL error), key IS NULL (matches nothing, so the target row is silently left behind while the position advances), or, when only part of a composite key is generated, a non-unique prefix of the key that deletes or overwrites target rows the source never touched. The change is refused, naming the table and the column. Bulk sluice migrate of the same table is unaffected — the cold copy reads the generated value with a plain SELECT; only CDC needs an identity carried on the wire. | Give the table a row identity made of non-generated columns and stream on that: add a surrogate PRIMARY KEY (or promote an existing NOT NULL UNIQUE column) and demote the generated column to an ordinary indexed column. On Postgres you can instead point ALTER TABLE … REPLICA IDENTITY USING INDEX at an immediate NOT NULL UNIQUE index over non-generated columns — but note REPLICA IDENTITY FULL is not a fix, because a generated column is unpublished under FULL too. If the source cannot change, use sluice migrate (one-shot bulk copy, no CDC) for that table, or exclude it with --exclude-table=<name> and keep the rest of the stream running. |
SLUICE-E-CDC-ROW-IMAGE-PARTIAL | refusal | The MySQL source streams partial binlog row images — binlog_row_image is MINIMAL or NOBLOB, or binlog_row_value_options is PARTIAL_JSON (both read at CDC start — sync cold-start, warm resume, and backup incremental alike; the binlog_row_value_options read is tolerant since the variable only exists on MySQL ≥ 8.0.3). Under a partial row image the binlog UPDATE before-image omits non-key columns and the after-image omits unchanged columns — and under PARTIAL_JSON the after-image carries JSON columns as diffs, not values — so sluice's CDC would silently lose every UPDATE: the stream stays green, row counts stay equal, only row content diverges (Azure Database for MySQL Flexible Server ships MINIMAL as its platform default, so every Azure-MySQL sync source hits this out of the box; DO/RDS/GCP ship FULL; PARTIAL_JSON is opt-in everywhere). sluice refuses at CDC start instead. A self-hosted Vitess source reaches the same silent-loss class through the VStream door: sluice talks to a vtgate, not to the underlying mysqlds, so there is no single global to preflight — instead the belt honors the RowChange.DataColumns bitmap Vitess carries on the wire, and refuses loudly mid-stream when the bitmap marks a column omitted from an UPDATE after-image (binlog_row_image=NOBLOB with Vitess's AllowNoBlobBinlogRowImage experimental flag, Vitess 16+) or flags a PARTIAL_JSON diff value. PlanetScale pins FULL, so the managed flavor never trips it. | Set the source to full row images: SET GLOBAL binlog_row_image=FULL and/or SET GLOBAL binlog_row_value_options='' (dynamic, no restart; applies to sessions opened after the change). On Azure Database for MySQL Flexible Server: az mysql flexible-server parameter set --resource-group <rg> --server-name <server> --name binlog_row_image --value FULL (~20 s, no restart). On self-hosted Vitess, set binlog_row_image=FULL on the cluster's mysqld tablets. Then re-run. Note the settings are per-session-at-connect: binlog segments already written under the partial mode stay partial, and a mid-stream partial image is refused loudly rather than applied — when in doubt, start the sync fresh after the flip. |
SLUICE-E-CDC-STANDBY-SOURCE | refusal | The source is a read-only hot standby / read replica (pg_is_in_recovery() = true) — e.g. a Supabase read replica (db.<ref>-rr-…supabase.co) or any streaming-replication standby. Raised by sync start, by the snapshot+CDC handoff, and — since v0.139.0 — by backup full, which refuses before reading a row rather than copying the whole database and then failing at position capture. Since v0.140.0 that refusal reaches a standby at ANY wal_level: it had been decided by whichever check ran first, so on the default wal_level=replica — what a plain read replica runs — the wal_level error won and the copy still happened. CDC has to manage the sluice publication on the source, and CREATE/ALTER PUBLICATION cannot run on a standby (pre-fix this surfaced as a raw SQLSTATE 25006 "read-only transaction" error at publication ensure). PG 16+ standbys can technically host logical slots, but slot creation blocks on the primary's next running-xacts record and managed platforms gate the nudge (pg_log_standby_snapshot() is often superuser-retained), so the primary is the supported CDC source. | Point --source at the PRIMARY endpoint (Supabase: db.<ref>.supabase.co, not the -rr- replica host). A standby/replica remains a fine source for bulk sluice migrate — the parallel snapshot-pinned copy works unreduced on PG 16+ standbys. See managed-services. |
SLUICE-E-CDC-UNLOGGED-TABLE | refusal | A Postgres CDC scope includes an UNLOGGED table. Unlogged tables write no WAL, so no logical-replication mechanism can ever stream their changes — and the two publication forms fail in opposite ways. A scoped FOR TABLE publication (single-schema sync) is refused by Postgres itself (cannot add relation … to publication / not supported for unlogged tables); this code fires the same refusal as a preflight, before any DDL touches the source. A FOR ALL TABLES publication — the form a multi-schema spanning sync must use (a logical slot is database-wide), and the form backup full --chain-slot ensures for its incremental chain — silently excludes the unlogged table: no error, no notice, it simply never appears in pg_publication_tables. The cold copy (and the full backup) DO include it, so without this refusal the target (or the backup chain) receives the table's initial rows and then freezes at the snapshot forever while the stream stays green — permanent silent divergence once later logged transactions advance the resume position past the unlogged writes' LSNs (observed end-to-end on PG 16, capture-completeness sweep 2026-08-26). The census honours the table filter: an unlogged table you already --exclude-tabled never trips it. Note ALTER TABLE … SET UNLOGGED on an already-synced table succeeds under FOR ALL TABLES (Postgres only blocks it for scoped membership), which is why the census re-runs at every spanning stream open, not just cold start; a flip during a live streaming window is still undetectable until the next open. | Exclude the table from the sync/backup scope (--exclude-table=<name>), or make it durable with ALTER TABLE <name> SET LOGGED (takes a rewrite lock; its writes then enter WAL and stream normally), then re-run. If the table is deliberately ephemeral (cache/scratch), excluding it is the right call — bulk sluice migrate can still copy it one-shot, since a plain copy needs no WAL. |
SLUICE-E-CDC-XA-UNSUPPORTED | refusal | A replicated table is written inside a MySQL XA (distributed) transaction. sluice applies CDC rows at read time, but an XA body's rows are not visible on the source until a LATER XA COMMIT — so applying them would fabricate rows on the target if the coordinator rolls back, and a position persisted mid-body is not a valid restart point (a crash could skip the body's tail). Faithful XA replication requires buffering prepared transactions the way a real replica does, which is demand-gated. XA transactions touching only NON-replicated tables stream past without this refusal, so a filtered sync sharing a server with an XA-using application keeps working. | Keep XA (distributed) transactions off the replicated tables, or exclude those tables from the sync (--exclude-table — the refusal honours the filter). If the stream keeps refusing on resume, the XA body is already in its past: a re-snapshot (sync start --restart-from-scratch) moves past it. If your application can use ordinary transactions for these tables, that also resolves it. If you need faithful XA replication, file the demand — the buffering design is known, unscheduled. |
SLUICE-E-CDC-PUBLICATION-SCOPE-CONFLICT | refusal | A Postgres cold start would narrow the publication — an ALTER PUBLICATION … SET TABLE (or a FOR ALL TABLES drop-and-recreate) that REMOVES tables — while another sluice replication slot exists on that source (active or not; since v0.99.289 a slot's existence is the conflict signal, because an inactive slot is a stream stopped mid-migration that will resume expecting its scope). The publication is pgoutput's table filter and nothing binds it to a slot, so the rescope would leave the other stream advancing (or resuming later) while it received nothing for its tables: a silent divergence with a green sync status / sync health. Reached most naturally by staged ("wave") migration — concurrent OR sequential streams over one PG source with different --include-table scopes. Widening or equal-scope rescopes never trigger it, so the ADR-0122 fleet shape and schema add-table are unaffected. | Give each stream its own publication: pass --publication-name (per-stream, same sluice_ prefix convention as --slot-name) on every stream over that source. Or drain the other stream (sluice sync stop --wait) and decommission it once it is finished for good (sluice sync decommission --stream-id <id> --yes drops its slot and per-stream publication and clears its control row; the raw-SQL equivalent is SELECT pg_drop_replication_slot('sluice_…') — the refusal labels each conflicting slot active/inactive); merely stopping it no longer clears the conflict, because a stopped stream's slot still claims its scope. See staged-wave-migration and ADR-0175. |
SLUICE-E-CDC-PUBLICATION-NAME-INVALID | refusal | sync start refused the operator-supplied --publication-name (or the fleet spec's publication-name) at resolve time because it is not a safe Postgres replication identifier: it must be lowercase [a-z0-9_] only and at most 63 bytes (after the automatic sluice_ prefix). Postgres itself would accept a mixed-case or over-length name — but only asymmetrically: sluice's CREATE PUBLICATION quotes the identifier and preserves its exact spelling, while START_REPLICATION's publication_names argument is downcased by the server (and a >63-byte name is silently truncated at CREATE with only a NOTICE, then matched verbatim at stream time). The stream would therefore create one publication and stream from another: green through the whole bulk copy, then a publication "…" does not exist (42704) at the first change — or a silently idle stream forever on a quiet source. This mirrors the charset Postgres enforces server-side on replication slot names (audit 2026-07-23 D0-9). | Rename the publication in the flag/spec to lowercase letters, digits, and underscores, ≤63 bytes including the sluice_ prefix (e.g. --publication-name wave_1 → sluice_wave_1). The refusal fires before anything touches the source, so no cleanup is needed. |
SLUICE-E-CDC-BINLOG-FORMAT-NOT-ROW | refusal | Binlog CDC (a vanilla MySQL or MariaDB source — sync start, warm resume, backup incremental) refused at CDC start because @@GLOBAL.binlog_format is STATEMENT or MIXED, not ROW. sluice's CDC replays ROW-format binlog row events; under STATEMENT the server logs DML as SQL text, which sluice deliberately never executes against the target — so the stream would run green while silently applying nothing: the target freezes at the cold-copy snapshot, the resume position never advances, and no error is ever raised (ground-truthed on a real mysql:8.0 STATEMENT source, 2026-07-23). MIXED is the same class — the server statement-logs every deterministic write, so most DML is still lost — and MIXED is MariaDB's default, so an un-tuned MariaDB source hits this out of the box. On a cold start the refusal fires before the bulk copy (both snapshot openers preflight); PlanetScale/Vitess (VStream) sources never take this path (vtgate owns the row-event contract), and bulk-only runs (migrate, backup full) never read the binlog and are not gated. | Set the source to row logging: SET GLOBAL binlog_format=ROW (dynamic, no restart; applies to sessions opened after the change — on managed MySQL/MariaDB use the provider console's binlog_format parameter), then re-run. Binlog segments already written under STATEMENT/MIXED stay statement-logged, so when in doubt start the sync fresh after the flip rather than resuming across it. |
SLUICE-E-CDC-REPLICATION-HEADROOM | refusal | A slot-creating Postgres CDC cold start (sync start with --source-driver postgres) refused up front because the source has no replication headroom for one more consumer: every max_replication_slots slot is already in use, or every max_wal_senders sender is attached (the message names each existing slot and whether it is active). Without this preflight the failure surfaced mid-cold-start as the raw ERROR: all replication slots are in use (SQLSTATE 53400) — after the schema read, with no inventory and no remedy. With per-stream publications (ADR-0175/0176) a multi-slot source is the documented staged-wave pattern, so hitting the default ceiling (10) usually means leftover slots from finished waves. Warm resume never trips this — a resume reuses the stream's existing slot and consumes no new one — and a probe failure (e.g. a managed platform restricting the stats views) degrades to a WARN and continues: the refusal fires only on a successful census that proves the ceiling. | Free a leftover: sluice slot list to inspect (the refusal names the slots too), sluice sync decommission --stream-id <id> --yes for a finished sluice stream (drops its slot, per-stream publication, and control row), or sluice slot drop <name> for an abandoned non-sluice leftover. Or raise the ceiling: max_replication_slots / max_wal_senders in postgresql.conf (restart required; on managed Postgres use the provider's parameter console), then re-run. |
SLUICE-E-CDC-MARIADB-UNSUPPORTED | refusal | RETAINED, NO LONGER EMITTED. This code refused MariaDB CDC during the item-73 Phase-1/2 window, when the reader could not parse MariaDB's domain-based GTID positions (e.g. 0-100-38). MariaDB CDC via domain GTIDs SHIPPED in v0.99.271 (ADR-0170): the mariadb flavor now declares CDCBinlog, so sync start, backup stream/incremental, and mid-stream schema add-table all work against a MariaDB source. The code string stays registered because removing a published catalog code is a breaking change, but sluice no longer emits it — kept only so an older log line or script that matched it still resolves. | None — MariaDB CDC is supported. Run sluice sync start (or backup incremental) against the MariaDB source normally. |
SLUICE-E-CDC-MARIADB-NATIVE-TYPE-UNSUPPORTED | refusal | RETAINED, NO LONGER EMITTED. This code refused a MariaDB native uuid / inet6 / inet4 column in CDC scope during the Phase-3 window, before a binlog decoder for those types existed (the binlog carries their raw storage bytes, not the text bulk copy reads, so a naive stringify would land a wrong value a CHAR(36)/VARCHAR(45) target silently accepts — the Bug-74 class). Faithful flavor-gated binlog decode of these types SHIPPED in v0.99.272 (ADR-0171) — the CDC tail now converges byte-for-byte with the bulk-copy text — so the refusal is lifted. The code string stays registered (removing a published code is breaking) but sluice no longer emits it. | None — native uuid/inet columns stream faithfully through CDC; no need to exclude the column or fall back to bulk migrate. |
SLUICE-E-CDC-REPLICA-NO-LOG-UPDATES | refusal | Binlog CDC (a vanilla MySQL or MariaDB source) refused at CDC start because the source is itself a replica (SHOW REPLICA STATUS returns a row — or, on MariaDB, SHOW ALL REPLICAS STATUS does: the bare form lists only the default connection, so a CHANGE MASTER 'name' TO … named multi-source connection is visible only to the ALL spelling, which sluice probes too) with log_replica_updates=OFF — writes replicated from its primary are applied by the SQL thread but never enter this server's own binlog, so sluice's CDC tail would carry only local writes: the replicated traffic is silently absent while the stream stays green (ground-truthed on a real linked mysql:8.0 pair, 2026-08-26). Sharper still, the replicated GTIDs land in gtid_executed and therefore gtid_purged, so on every GTID-mode restart the resume-reachability check fires the automatic re-snapshot — a perpetual silent-window/re-snapshot loop misdiagnosed as retention loss (in file/pos mode there is no tripwire at all). The refusal fires only on the conjunction: a replica with log_replica_updates=ON is a legitimate chained-replication source and passes, as does a non-replica with the variable off. MySQL 8.0 defaults the variable ON; MariaDB and MySQL 5.7-era servers default it OFF, so an untuned MariaDB replica is blind by default. This is the MySQL twin of the Postgres standby refusal (SLUICE-E-CDC-STANDBY-SOURCE). If the status probe itself fails in every spelling (MariaDB 10.5+ splits it behind REPLICA MONITOR), sluice WARNs that the check is degraded rather than refusing a working configuration; MySQL's syntax error on the MariaDB-only ALL spellings is expected and does not degrade the check — the bare form already lists every channel there. | Point the sync at the primary endpoint, or restart the replica's mysqld with log_replica_updates=ON (--log-replica-updates / my.cnf — the variable is read-only at runtime, so SET GLOBAL cannot fix it), then re-run. A replica remains a fine source for bulk sluice migrate — replicated rows are SQL-visible; only the CDC tail is blind. |
SLUICE-E-CDC-BINLOG-DB-FILTERED | refusal | Binlog CDC refused at CDC start because the source mysqld was started with server-side binlog filters (--binlog-ignore-db / --binlog-do-db) that exclude a synced database from the binlog: its writes are applied and SQL-visible — the cold copy completes — but they are never written to the binlog, so the live CDC tail would be silently empty for that database while the stream stays green (ground-truthed on real mysql:8.0.46, 2026-08-26; under ROW format the filter keys on the changed table's actual database, not the session default). The check is scoped to the databases the sync actually reads — a filter covering only unrelated databases passes — and mirrors the server's own rule that a non-empty do-list makes the ignore-list moot. The filters are startup options, not dynamic variables, so the start-time preflight is authoritative for the life of the server process; it re-runs at every CDC (re)open, covering a filter added across a mysqld restart mid-sync. The evidence rides the same SHOW MASTER STATUS / SHOW BINARY LOG STATUS row sluice already reads (the Binlog_Do_DB / Binlog_Ignore_DB columns, MySQL and MariaDB alike). | Remove --binlog-ignore-db / --binlog-do-db from the source mysqld's startup options (my.cnf or command line) and restart it — the filters cannot be changed at runtime — or take the filtered database out of the sync's scope. Then re-run; writes made while the filter was in effect are not in the binlog, so start fresh rather than resuming across the window. |
SLUICE-E-CDC-STATEMENT-DML | refusal | Binlog CDC stopped mid-stream because a row-DML statement (INSERT/UPDATE/DELETE/REPLACE, or a WITH-prefixed CTE-DML — WITH … UPDATE/WITH … DELETE, enumerated since v0.134.0 because a WITH … SELECT performs no writes and is never binlogged under any format, so a WITH-prefixed QUERY event can only be statement-format CTE-DML) arrived as binlog QUERY-event text — or, for a statement-format LOAD DATA, as an EXECUTE_LOAD_QUERY event (same refusal, verb LOAD DATA). Under ROW logging DML is written as row events, so statement text here is proof of a statement-logged write — a SUPER session's SET SESSION binlog_format=STATEMENT override that slips the GLOBAL preflight (SLUICE-E-CDC-BINLOG-FORMAT-NOT-ROW's documented residue), or a resume replaying a binlog segment recorded before the global was flipped to ROW. sluice deliberately never executes replayed SQL text against the target (replaying arbitrary SQL against a possibly-different-engine target is not faithful CDC), so before this belt existed such statements fell into the generic DDL arm and were silently dropped — schema cache cleared, nothing applied, no error. The belt is scoped like the rest of the dispatch: a statement whose session default database is outside the sync's scope does not stop the stream — unless the statement itself qualifies a table with a synced database (USE other; INSERT INTO src.t …), which is in scope. Since audit 2026-09-01 (SLM-3) the four shapes that used to be documented residue are closed: statement-format LOAD DATA, DML wrapped entirely in a /*!NNNNN … */ versioned comment (the server executes its contents, so the lexer lexes inside it), -- followed by a newline (MySQL's -- comment rule is whitespace OR a control character; the old lexer accepted only space and tab), and cross-database DML from an out-of-scope session. An in-scope QUERY event carrying non-comment text that lexes to no keyword at all is refused too (verb unrecognised) rather than falling to the generic DDL arm; comment-only text (MariaDB's # Dummy event … padding for a suppressed ANNOTATE_ROWS) still flows. MariaDB's DELETE HISTORY maintenance statement is exempt (the one legitimate statement-shaped DELETE under ROW format). | Find and clear the writing session's override (SELECT t.PROCESSLIST_ID, v.VARIABLE_VALUE FROM performance_schema.variables_by_thread v JOIN performance_schema.threads t USING (THREAD_ID) WHERE v.VARIABLE_NAME='binlog_format'). That lookup works on MySQL only (where it requires performance_schema=ON, the default). On MariaDB the query hard-errors regardless of the setting — variables_by_thread is not in MariaDB's performance_schema table set at all (verified live on 11.4: error 1146 with the setting ON and OFF alike), so enabling performance_schema cannot fix it; on MariaDB find the writer by interrogating candidate sessions from SHOW PROCESSLIST. Then ensure @@GLOBAL.binlog_format=ROW with no session overrides, and start the sync fresh (sync start --restart-from-scratch): the statement-logged writes are not replayable from the binlog, a resume would deterministically re-refuse at the same event, and only a fresh snapshot recopies them. To identify which statement it was: the refusal deliberately withholds the statement's values — binlog text carries row values, which would otherwise ride the refusal into logs and reports past --redact — and names the event's binlog coordinate instead — each component when known: file and end position (position alone until the dump's opening ROTATE), commit timestamp, and the GTID in GTID mode; with no header at all it says so rather than inventing one. The refusal still carries a sanitized leading text — verb, table and leading column names, capped at 80 bytes, with every literal cut away. Use that with mysqlbinlog against your own server. It replaced a short digest of the statement text, which was an oracle: against a known statement template with one low-entropy unknown, a recomputable digest usually determines the withheld value. |
SLUICE-E-CDC-TRIGGER-ECHO-LOOP | refusal | Replicated-write capture (sluice trigger setup --capture-replicated-writes, ADR-0185) was refused because the postgres-trigger source also carries sluice's own apply bookkeeping (sluice_cdc_state) — this database is (or was) the TARGET of another sluice sync. The opt-in installs the capture triggers ENABLE ALWAYS, so they fire for writes applied under session_replication_role = 'replica' too — which is the point (a native logical-replication subscriber's apply workers write that way), but it equally captures the upstream sluice sync's own applied rows: every row that sync applies here would be re-captured and forwarded as a NEW change — an echo loop (unbounded re-application in a cyclic topology, duplicated fan-out otherwise). The refusal fires at trigger setup (dry-run included) and again at every CDC open — the relay shape can appear after setup, so the posture is re-vetted each stream start. The probe is DATABASE-WIDE (every non-system schema), not scoped to the capture schema, and the refusal names the schema(s) where the bookkeeping was found: --target-schema moves the user data while the applier leaves sluice_cdc_state in the target DSN's schema, so the two are designed to diverge and a one-schema probe would miss the loop it exists to refuse (fixed 2026-08-31, audit SEC-4). Where a schema's stream count cannot be read the refusal says stream count unavailable — the detail read failed rather than printing a 0 indistinguishable from an empty control table. Without the opt-in the same relay shape is the SILENT-CAPTURE-GAP RISK WARN, never this refusal. A probe failure under the opt-in also refuses (fail-closed): a refusal-gating check must not degrade to a pass. | Capture from the ORIGIN database instead of relaying through this one (point the trigger-CDC sync at the database where the writes originate). If the upstream sluice sync is finished for good, decommission it and drop its control table (sluice sync decommission --stream-id <id> --yes on that sync, then DROP TABLE <schema>.sluice_cdc_state if bookkeeping remains), and re-run. Or install without --capture-replicated-writes — origin-only capture with the replica-role WARN. |
SLUICE-E-CONNECT-IPV6-ONLY | runtime | The DSN host failed to resolve from this machine but carries an AAAA (IPv6) record — the host is IPv6-only and this network appears IPv4-only. Seen on Supabase free-tier direct endpoints, where IPv4 is a paid add-on. | For bulk migrate, use the provider's pooler endpoint (it has an A record); for CDC, the direct endpoint is required — Supabase's pooler strips the replication parameter — so enable the provider's IPv4 add-on or run sluice from an IPv6-capable network. See managed-services. |
SLUICE-E-COLDSTART-TARGET-NOT-EMPTY | refusal | Cold-start refused: a target table already contains data (usually a previous run died mid-copy). | Sync: re-run with --reset-target-data --yes. Migrate: use --resume. Either mode: --force-cold-start to copy into the populated table anyway (collides on PRIMARY KEY in most cases). |
SLUICE-E-TARGET-DEFERRABLE-KEY | refusal | sluice sync (or any other idempotent apply/copy path) refused because a target table's primary key — or the only unique key sluice could key an upsert on — is DEFERRABLE. Postgres rejects a non-immediate index as an ON CONFLICT arbiter (ON CONFLICT does not support deferrable unique constraints/exclusion constraints as arbiters, SQLSTATE 55000), and every idempotent write sluice makes is an ON CONFLICT (key) DO UPDATE — the CDC applier's per-change upsert and the idempotent bulk-copy writer's batch upsert both need an arbiter. v0.103.1 made a Postgres target CARRY a source's PRIMARY KEY … DEFERRABLE, which is correct (dropping it landed a stricter constraint than the source's, aborting bulk key shifts that commit on the source) — and carrying it is what made the apply path illegal. Before this refusal existed the stream died on the first change to such a table with no retry, every warm resume died on the same change, and every other table in the same stream stalled behind it (Bug 211). sluice still carries the attribute faithfully; what it refuses is to keep streaming into a shape it cannot upsert into. The check runs at sync start once the target schema exists and before the first change is applied, and again on first sight of the table for the paths that have no preflight (broker replay, chain restore, schema add-table). DEFERRABLE INITIALLY IMMEDIATE is refused too: Postgres clears pg_index.indimmediate for any deferrable constraint, so it is equally unusable as an arbiter. | Recreate the target constraint as immediate — ALTER TABLE t DROP CONSTRAINT t_pkey; ALTER TABLE t ADD CONSTRAINT t_pkey PRIMARY KEY (...); (NOT DEFERRABLE is the default) — then re-run; the stream warm-resumes from where it refused. Or pre-create the target table with an immediate primary key before the first sync start, so the deferrable source shape is never carried onto it. Any immediate NOT NULL UNIQUE index on the target table also clears the refusal: sluice keys the upsert on that instead of the primary key. Otherwise take the table out of scope with --exclude-table. There is no flag to proceed anyway — a plain INSERT fallback would fail on the first replayed change, which is the wedge this refusal exists to prevent. |
SLUICE-E-TARGET-TABLE-SHAPE-MISMATCH | refusal | migrate refused before any data moved: a target table with the same name already exists but its column shape — names (order-insensitive), types, nullability — differs from what the migration would create. migrate's table creation is CREATE TABLE IF NOT EXISTS, so before this gate a conflicting pre-existing table was silently tolerated and only failed mid-copy (an Unknown column error retried for the full transient-retry wall). A pre-existing table whose column shape MATCHES is skipped with an INFO instead (indexes/constraints/defaults are deliberately outside the compare — a pre-created table legitimately carries them; later phases create any missing ones idempotently). The message names the table and the first differing columns, expected vs actual. | Drop or rename the conflicting target table, exclude it with --exclude-table, or alter its shape to match sluice schema preview's output; --reset-target-data --yes drops every in-scope target table first. On --resume the gate does not run (the prior attempt's own tables re-create idempotently). |
SLUICE-E-TARGET-PREEXISTING-FOREIGN-KEY | refusal | migrate or a sync cold start refused before any data moved: the target already carries a foreign key on a table this run copies into, and that constraint's parent table is copied by the same run. sluice's deferred-constraint discipline — create the tables bare, copy, then add indexes and constraints — governs only the constraints sluice creates. A target branched from an existing database arrives with its own, the bulk copy is not parent-first ordered (tables are copied in parallel), so a child row reaches the target before its parent and the constraint rejects it: MySQL Error 1452 (23000): Cannot add or update a child row, Postgres SQLSTATE 23503. A field report died ~20 seconds into a cold start on exactly this, with nothing having warned first (roadmap item 140). A foreign key whose parent table is not in scope is NOT refused — the parent rows already on the target may well satisfy every child row — it gets a WARN naming the constraint instead. A SQLite target is never refused: every writable connection opens with PRAGMA foreign_keys=0, so its copy cannot fail this way, and the post-copy PRAGMA foreign_key_check still reports a genuine violation. | Drop the named foreign keys on the target for the duration of the copy and re-add them afterwards (ALTER TABLE … DROP FOREIGN KEY … on MySQL, ALTER TABLE … DROP CONSTRAINT … on Postgres), adding --skip-foreign-keys if you do not want sluice to re-create them at the end. Or take the child tables out of scope with --exclude-table. Or, only if the entire target dataset is disposable, --reset-target-data drops every in-scope target table and re-creates it WITHOUT constraints, so the copy runs unconstrained and the foreign keys are added back after it. Note that --skip-foreign-keys alone does not clear this: it governs only the constraints sluice would create, never one the target already has, so a run passing it would still die on the same rejection. |
SLUICE-E-SCHEMA-EXTENSION-NOT-ENABLED | refusal | A column's type is owned by a PostgreSQL extension the operator has not opted into. | Pass --enable-pg-extension <ext>; see type-mapping. |
SLUICE-E-VALUE-ZERO-DATE | refusal | A MySQL zero/partial date (0000-00-00 …) has no valid calendar value the target can hold. | Pass --zero-date=null or --zero-date=epoch to carry it; see migrating-legacy-mysql. |
SLUICE-E-VALUE-NUL-BYTE | refusal | A string value carries a NUL byte (0x00), which PostgreSQL text types cannot store. | Clean the source data, or map the column to bytea with --type-override COL=bytea. |
SLUICE-E-VALUE-BYTEA-TEXT-UNRECOGNIZED | refusal | A PostgreSQL bytea value arrived as a TEXT rendering sluice cannot read — not the \x-prefixed, even-length hex form that bytea_output = hex (the PostgreSQL default) produces. Three paths carry PG's text spelling and can reach it: a pgoutput CDC tuple column, each element of a bytea[] array literal, and — cross-engine — a natively-binary column whose value arrived from the postgres-trigger capture as the \x-hex JSON string, on its way into a MySQL BLOB/VARBINARY. The likely cause is a source running bytea_output = escape, whose \001\002 rendering has no faithful reading here. Before this refusal existed the decoder decided hex-versus-raw by CONTENT and fell through to a verbatim copy, storing the ASCII bytes of the rendering as the value — and that same content sniffing silently SHRANK genuine binary values that happened to spell the hex form, on every cold-copy and backup lane: a 6-byte value spelling \xdead decoded to 2 bytes, and the 2-byte value \x decoded to zero bytes. Provenance now decides the reading, so a driver-decoded value is never sniffed and a text rendering is never guessed at. | Set the source's bytea_output back to the hex default (ALTER DATABASE <db> SET bytea_output = 'hex') and re-run — sluice does not decode the escape rendering. If the source is already on hex, the value reached the decoder mangled: report it as a sluice bug, quoting the column named in the message. |
SLUICE-E-VALUE-UNREPRESENTABLE | refusal | A value no target column type can represent — e.g. a NaN/±Infinity float from a PostgreSQL double precision source into a MySQL FLOAT/DOUBLE (MySQL has no NaN/Infinity). Since v0.122.0 it also fires for a geometry carrying NaN/Inf coordinates toward a MySQL-family target — including PostGIS POINT EMPTY, whose WKB representation IS a NaN/NaN point — and for structurally undecodable WKB: MySQL accepts the raw bytes silently and stores a value it cannot itself read back (ST_AsText NULL, error 3037 on access), so sluice refuses client-side before the wire. Refused before the driver sees it so it fails loudly instead of corrupting the value or retry-looping on the server's misleading error. | Filter or transform the source value (NULLIF / CASE for floats; for geometry, filter with NOT ST_IsEmpty(col) or repair/exclude the offending rows — NULLIF cannot express the POINT EMPTY case). |
SLUICE-E-EXPR-BACKSLASH-LITERAL | refusal | A SQLite expression's string literal contains a backslash (or a double-quoted token), which MySQL would silently reinterpret under its default sql_mode. | Rewrite the expression on the SQLite source, or re-create it on the MySQL target post-migration. |
SLUICE-E-CONFIRMATION-REQUIRED | refusal | A destructive command was run without --yes. sluice is non-interactive and never prompts, so it refuses loudly instead of blocking on a prompt (slot drop and sync decommission are the current callers; sync decommission --dry-run is exempt — it touches nothing). | Re-run with --yes (or -y) to confirm the destructive operation. |
SLUICE-E-DDL-EMIT-MULTI-STATEMENT | refusal | A DDL statement sluice was about to execute (Postgres and SQLite targets carry this door; MySQL's driver refuses multi-statement strings on its own) is structurally MORE than one statement — a top-level ; with SQL after it, an unbalanced paren, or an unterminated quote. sluice's emitters only ever produce one statement, and the restore path inlines recorded expression bodies (CHECK constraints, generated columns, defaults) from the backup manifest verbatim into that DDL — so this shape means the recorded content is corrupt or tampered, and executing it would run the extra SQL as the connected role. Nothing was executed. | If this fired during a restore: verify the chain's signature (backup verify --verify-key …) — a signed chain that verifies but still trips this door indicates corruption at record time, and a fresh backup full of the live source re-records the schema. If the source is gone, restore --exclude-table=<the named table> skips the affected table; the data chunks are unaffected. |
SLUICE-E-DECOMMISSION-STREAM-ACTIVE | refusal | sync decommission refused because the stream's replication slot is active on the source — a CDC consumer (walsender client) is attached, so the stream is live (or another consumer has hold of its slot). Decommissioning a running stream would yank the slot and publication out from under it mid-stream; the refusal fires before anything is touched, so a refused attempt changes nothing on either side. | Drain the stream first: sluice sync stop --stream-id <id> --wait, then re-run sluice sync decommission. If the slot stays active with no sluice process running, another consumer holds it — investigate with sluice slot list before removing anything. |
SLUICE-E-DRIVER-HOST-MISMATCH | refusal | The chosen driver cannot drive the server it is pointed at. Three arms raise it: (1) a non-VStream MySQL flavor (mysql or mariadb) pointed at a PlanetScale MySQL endpoint (*.connect.psdb.cloud / *.private-connect.psdb.cloud), whose binlog CDC and LOAD DATA cold-copy Vitess blocks — decided from the DSN string alone, before any connection, on migrate and sync; (2) since v0.125.0, the plain mysql flavor pointed at ANY server whose VERSION() reports Vitess (a self-hosted vtgate, or hosted PlanetScale however addressed) — a connect-time fingerprint on every schema-reader/writer open, i.e. every migrate / sync / backup / restore / verify run — because the vanilla flavor's full scans run without set workload=olap and Vitess's default OLTP workload SILENTLY truncates large result sets at its row cap; (3) the mariadb flavor pointed at a server that is not MariaDB — the same connect-time fingerprint — because its catalog queries and COLUMN_DEFAULT normalization are MariaDB-specific and would mis-read a MySQL/Percona server's defaults. | Pass --source-driver planetscale / --target-driver planetscale for the PlanetScale endpoint (or vitess for a self-hosted Vitess); for the mariadb-flavor arm, mysql (or planetscale/vitess) for the server that is not MariaDB. |
SLUICE-E-INDEX-MISSING | refusal | The post-copy verification found the target is missing one or more secondary indexes the migration was expected to build (named as table.index in the message) — a loud-failure safety net against a silent index-build no-op. sluice refuses to report a successful migration with an incomplete schema. The check runs on every MySQL-family target (mysql, mariadb, planetscale, vitess) and on PostgreSQL alike; the PostgreSQL verifier additionally refuses when an index EXISTS under the expected name but is not UNIQUE where a UNIQUE index was requested, because a same-named non-unique index is exactly what a silently-no-op'd CREATE UNIQUE INDEX IF NOT EXISTS leaves behind — the target would accept duplicate rows the source rejects. | Re-run with --resume to rebuild the missing indexes; if it recurs, the target rejected the index DDL — check the target's DDL/online-migration policy and the logs for the underlying error. For the not-unique shape, look for a pre-existing index of that name on the target (or a name collision on the source, which sluice refuses up front as SLUICE-E-SCHEMA-INDEX-NAME-COLLISION) and drop it before re-running. |
SLUICE-E-VSTREAM-FLOAT-LOSSY | refusal | backup full or sync start on a PlanetScale/Vitess (VStream) source with --strict-float, when a single-precision FLOAT column cannot be re-read exactly. Both commands drive the same display-rounding VStream COPY reader and the same repairable/un-repairable classification. Shared cause: the table is keyless / float-PK-only (no primary key to target the exact re-read — refused upfront, before any row moves). backup full adds two of its own: the table is larger than --float-reread-max-rows (too large for the bounded-memory exact re-read — refused when reached), or the exact re-read returned rows but not one streamed row's primary key matched (a systemic PK-rendering divergence — the exact map has rows yet every archived row would silently retain its display rounding; refused when the table finishes streaming). sync start adds one: --strict-float passed together with --no-float-exact-reread, which are contradictory (demand exact / disable the repair that produces it). vttablet's rowstreamer renders FLOAT at mysqld's 6-significant-digit display precision, and --strict-float demands exact-or-fail. | Add a primary key (or exclude the table), raise --float-reread-max-rows if it's a size cap on backup full and you have the headroom, pass exactly one of --strict-float / --no-float-exact-reread, or drop --strict-float (the default repairs exact where it can and retains the rounding — with a WARN — elsewhere). A target-side --type-override to DOUBLE does NOT help — the source value is already rounded on the wire. |
SLUICE-E-BACKUP-SIGNATURE-INVALID | refusal | A signed (FormatVersion 6, ADR-0154) backup manifest's detached signature failed verification — the manifest was tampered, rolled back to an older version, its change-list was truncated, the wrong key was supplied, or the signature scheme/algorithm was relabeled (a signature of one scheme presented as another, or a KMS algorithm downgraded). Applies to all three signing schemes: HMAC-off-KEK (--sign, Phase 1), Ed25519 (--sign-key <pem>/--verify-key <pem>, Phase 2), and KMS (--sign-key kms://.../--verify-key, Phase 3). sluice refuses to restore/verify it before any data lands. | Restore from an untampered copy of the backup; if the whole store is suspect, the signature caught exactly the substitution it exists to catch. Verify the key matches the chain — the chain's --encrypt passphrase for an HMAC-signed chain, the correct --verify-key (a public-key PEM for Ed25519 / KMS, or kms://... to fetch the trusted key) for an asymmetrically-signed chain (the KEK does NOT verify an asymmetric signature). The recorded manifest key reference is never trusted — verification anchors on the key you supply. |
SLUICE-E-BACKUP-SIGNATURE-MISSING | refusal | A signed (FormatVersion 6) backup manifest asserts a detached signature but none is present (or the lineage catalog's signature is absent), OR --require-signature was set and the chain could not be verified with the supplied key material — the tamper signal for a dropped signature, a signed-chain manifest replaced without re-signing, or a signed chain restored without the matching verify key under strict policy. | Restore from a copy whose .sig objects are intact; supply the matching verify key (--encrypt passphrase for HMAC-off-KEK, --verify-key for Ed25519 / KMS); a maintenance run (compact/prune) that could not re-sign must be re-run with the chain's signing key material (--encrypt passphrase, --sign-key <pem>, or --sign-key kms://...) — that re-run's heal preserves the non-verifying signature verbatim (lineage.json.sig.pre-heal-<ts>) and appends a durable record to maintenance-heal.log in the backup store, and every later signature check — backup verify, chain restore, single-manifest restore, export-as-parquet, and the broker gate — reports the heal, so a re-sign is never invisible after the fact. |
SLUICE-E-BACKUP-SIGNATURE-UNSUPPORTED | refusal | A signed backup manifest records a signature scheme FAMILY (e.g. a future post-quantum scheme) or a kms/<algorithm> whose algorithm this build of sluice does not know how to verify, or a canonicalization version newer than this build supports — the signature was written by a NEWER sluice. This is a forward-incompatibility, NOT a tamper signal: sluice fails closed (it will not restore/verify what it cannot check) but does not claim the backup is compromised. Distinct from -INVALID precisely so a version/scheme gap is never mistaken for an attack. | Upgrade sluice to a build that supports the backup's signature scheme/canonicalization, then re-run the restore/verify. The backup is not necessarily tampered — an older binary simply cannot verify a newer signature format. |
SLUICE-E-BACKUP-MANIFEST-INVALID | refusal | A backup manifest — or the chain of manifests — fails an internal-consistency check, refused before any data lands. Three shapes: (1) the recorded BackupID does not match its content, recomputed at restore/broker time from the fields the id deterministically covers (created_at / source_engine / kind / EndPosition); (2) the recorded SchemaHash does not match the manifest's own schema, recomputed at chain-restore time AND (since v0.104.5) at backup verify time, on the same chain-shaped lineages restore checks — this shape has TWO causes the check cannot tell apart, so the refusal names both: the manifest was written by a sluice release whose IR schema field set differs from the running binary's (the fingerprint of an unchanged schema moves across such a boundary, so the chain is intact and unreadable only by this binary — see Schema-fingerprint epochs), or the manifest is genuinely corrupt/tampered; (3) a mixed-mode lineage, where a segment's full and one of its incrementals disagree on encryption (one encrypted, one plaintext) — a mis-stitched or tampered chain; (4) a branching lineage — an incremental whose parent_backup_id is not the link preceding it in lineage.json (… does not chain off preceding link … — branching/mis-stitched lineage), raised by backup verify and by restore, which build the chain the same way. Shapes (1)–(3) catch bit-rot, truncated rewrites, or a lazy tamper that edited a covered field without recomputing its fingerprint. They are corruption backstops, not tamper-proofing: a fully-coherent edit that also recomputes the fingerprints and fixes the parent-link chain is the signed-only boundary. | Restore from an untampered copy of the backup. For shape (4), see Repairing a forked chain — do not assume a prune/compact caused it; the usual cause is two concurrent chain writers, and that repair is lossless. For shape (2) read the writing release the refusal prints first: if it is not this binary's version, the chain is most likely intact, and the way forward is to restore it with a release from its own fingerprint epoch (Schema-fingerprint epochs) or to re-take the backup with this binary — there is no flag that skips the check. To close the coherent-edit residual and catch this at backup verify time, sign the chain (--sign / --sign-key + --require-signature). Genuine corruption from a bad upload produces the same refusal; re-fetch/re-stream the manifest. |
SLUICE-E-BACKUP-INCOMPLETE | refusal | A restore, broker replay, or export-as-parquet decoded/applied a DIFFERENT number of changes/rows than the manifest records — the signing-independent backstop against silent truncation or edit of an unsigned backup (ADR-0154 R2 residual). Three shapes: (1) after replaying an incremental's change chunks the last applied change did not REACH the manifest's EndPosition (a store adversary dropped the tail change-chunk entries; survivors keep their ordinals so every GCM AAD still validates, but the intact EndPosition would overstate the data and poison a resumed CDC stream); (2) a table's/chunk's decoded row total disagrees with the manifest's recorded positive RowCount (the layer-2 count check, on restore and on Parquet export alike); (3) a full's table or chunk records 0 rows yet decodes rows (a zeroed RowCount that would otherwise disable the layer-2 backstop). A fully-coherent manifest edit that also lowers EndPosition/RowCount stays a recoverable whole-backup rollback (the documented signed-only boundary); this code closes only the UNRECOVERABLE truncation variant. | Restore/export from an untampered copy of the backup. To catch this class at backup verify time (before a restore) and to close the residual coherent-edit boundary, sign the chain (--sign / --sign-key) so the manifest signature covers the change-chunk ordinal + count + positions — an unsigned encrypted incremental is protected against tail-truncation only by this replay-time backstop. Genuine truncation from a bad upload produces the same refusal; re-fetch/re-stream the incremental. |
SLUICE-E-BACKUP-CHUNK-AUTH-FAILED | refusal | An encrypted backup chunk failed its AES-GCM authenticated-decryption check during restore (or broker replay): the ciphertext or its bound additional-authenticated-data (AAD) does not match what was sealed — a tampered/corrupt chunk, or a spliced/reordered store where a chunk was moved between positions or between two same-column-set tables (the ADR-0152 position binding + ADR-0154 SEC-F1/SEC-1 parent-table binding, rendered with ADR-0181's injective length-prefixed encoding from FormatVersion=9 so no delimiter embedded in a table name or chunk path can make two distinct parents seal alike). This is the loud, coded TWIN of SLUICE-E-BACKUP-SIGNATURE-INVALID for a backup that is ENCRYPTED but NOT SIGNED, and it refuses before any row lands. It also fires from backup verify when key material is supplied: verify performs the same authenticated open restore performs, so a swap/splice/wrong-key chunk is caught BEFORE a recovery rather than during one (previously verify was sha256-only in per-chain mode and reported such a chain healthy — Bug 215). By the time a chunk decrypts the chain key has already unwrapped (its wrap is itself authenticated), so this is never a wrong-passphrase error — a wrong key is caught earlier at the key unwrap. | Restore from an untampered copy of the backup; if the whole store is suspect, the AAD binding caught exactly the chunk substitution/splice it exists to catch. Run backup verify WITH --encrypt + the chain's key material to catch this class ahead of a restore (the key-less depth is sha256-only and cannot see it — check the reported decrypted= count, not just the exit status). Signing the chain (--sign / --sign-key + --require-signature) additionally covers manifest-level edits an authenticated open cannot see. Genuine bit-rot (not tampering) produces the same refusal; re-fetch the chunk object or restore from a healthy replica. |
SLUICE-E-BACKUP-CHUNK-CORRUPT | refusal | A backup chunk's STORED bytes do not hash to the SHA-256 recorded for it in the manifest, checked by rehashing the bytes during restore, broker replay, or backup verify. This is the byte-level integrity check that runs BEFORE decryption, so it fires on plaintext and encrypted chunks alike — the integrity TWIN of SLUICE-E-BACKUP-CHUNK-AUTH-FAILED (which is the AES-GCM authenticated-decryption check). It catches at-rest corruption / bit-rot and any tamper that altered a chunk's stored bytes (for an encrypted chunk a byte flip is caught here first, before the GCM tag). sluice refuses before any row lands, exit-3 Refusal class. | Restore from an untampered / healthy copy of the backup, or re-fetch the chunk object from the store (a bad upload or storage bit-rot produces the same refusal as a tamper). If you want tamper caught at backup verify time and covered by a manifest signature, sign the chain (--sign / --sign-key + --require-signature). |
SLUICE-E-BACKUP-CHAIN-CONFLICT | refusal | Two writers on one backup chain. Two shapes, same code. (1) Lost catalog write — another writer advanced this chain's lineage while this operation (a backup full/backup incremental finalize, a backup stream rollover, a rotation COMMIT, backup compact, backup prune, or backup verify --rebuild-catalog) was in flight. Every chain writer read-modify-writes the shared lineage.json; the catalog write is a compare-and-swap on the chain's write-generation (a conditional create of lineage.gen/g-<N> — local FS O_EXCL, object stores If-None-Match), so the losing writer refuses loudly and writes NO catalog change; the named marker records the other writer's host/pid/claim-time. Detection requires a store with conditional-write support (local --output-dir, S3, GCS, Azure; an S3-compatible endpoint that lacks conditional PUTs degrades to the old unguarded behavior with a WARN). (2) Chain fork refused (v0.104.7) — this run's backup incremental/backup stream rollover chains off a parent that is no longer the chain's tip, because another writer extended the chain while this run's CDC window was open. The parent is resolved BEFORE the window opens, so two overlapping backup incremental cron entries both resolve the SAME parent; committing the second one would put two incrementals under one parent — a FORK, which backup verify and restore then refuse permanently while the chain keeps accepting further incrementals off whichever sibling won the tail. The refusal fires before this run's manifest is written, so nothing durable is added. Note the CAS in (1) cannot catch (2): both writes are correctly serialised; what is wrong is the link, not the write. | Check for a duplicate cron/scheduler entry or a concurrent backup/compact/prune/stream against the same chain; let the other writer finish, then re-run — the re-run extends the real tip and captures the window the refused run gave up. The refused operation left the catalog untouched (for shape 1, a refused backup's own manifest may be durable; it is re-cataloged by the next writer or stream resume). If conflicts persist, inspect the newest lineage.gen/ marker to identify the competing host/pid. Longer term: run one writer per chain — an overlapping cron is the shape both of these exist to catch. |
SLUICE-E-BACKUP-ENCRYPTION-MISMATCH | refusal | The supplied encryption configuration does not match the chain's recorded encryption metadata, refused at preflight before any chunk is read or written. Two shapes, on every chain-reading surface (restore, chain restore, sync from-backup, backup export-as-parquet, backup verify's decrypt preflight, the catalog-rebuild codec probe) AND on the chain-EXTENDING writers (backup incremental, the backup stream run streaming rollover — both align against the parent chain's recorded shape before writing a single chunk): (1) the chain records ChainEncryption (it is encrypted) but no --encrypt + key material was supplied — the message names the chain's algorithm, kek_mode, and kek_ref so you know exactly what to bring; (2) key material WAS supplied but its KEK mode (passphrase vs a KMS provider) differs from the chain's recorded kek_mode — the wrong KIND of key, caught before an unwrap attempt could produce a confusing decrypt failure. (The inverse mismatch — a key supplied against a chain that CLAIMS plaintext — is the downgrade-tamper signal and stays SLUICE-E-BACKUP-CHUNK-AUTH-FAILED.) | Pass --encrypt with the key material the chain was written under: --encryption-passphrase{,-env,-file} for kek_mode=passphrase-argon2id, --kms-key-arn for aws-kms, --gcp-kms-key-resource for gcp-kms, --azure-key-vault-id for azure-kms — the refusal message names the recorded kek_mode/kek_ref. A wrong passphrase/key of the RIGHT mode fails later at the CEK unwrap instead ("wrong passphrase / KMS key?"). |
SLUICE-E-BACKUP-CHUNK-UNREADABLE | refusal | A backup chunk is byte-INTACT and still unreadable. Its stored bytes hash to the SHA-256 the manifest records, and — on an encrypted chain verified with key material — its ciphertext opens under the same CEK and AAD binding restore uses; the chunk's own reader then cannot decode what is inside. The shapes: a single row/change line longer than the format's 64 MiB per-line limit (an artifact written by a sluice at or below v0.111.1, whose writers had no such refusal — Bug 226); a codec stream truncated or re-compressed with a re-stamped SHA; a segment whose recorded codec is not the codec its chunks were actually written with; a row whose tagged-value envelope this build's decoder rejects. Raised ONLY by sluice backup verify --depth read, which streams every chunk through the real ChunkReader/ChangeChunkReader and discards the rows. The hash-only default depth cannot see this class at all, by construction: it re-hashes the same bytes it is checking, so it confirms the artifact is intact while saying nothing about whether it can be read back. A chunk raising this code will fail restore — this is the pre-emptive form of that failure, raised while you still have time to take another backup. | Take a fresh backup of the source with a current sluice: since v0.112.0 every write core REFUSES an over-long row at backup time rather than producing an artifact that verifies clean and never restores, so the re-taken backup either succeeds or names the offending table and row up front. If the source is gone and this chain is the only copy, the chunk named in the message is the whole loss — the other chunks are unaffected and restore --include-table can still recover the rest. A truncated/re-compressed chunk with a matching SHA is a store-side rewrite: re-fetch that object from a healthy replica, and sign the chain (--sign / --sign-key + --require-signature) so a manifest-level edit is caught too. Note the parse proves the chunk DECODES, not that its values are correct — --depth read is a readability check, not a content check. |
SLUICE-E-SCHEMA-TARGET-KEYSPACE-SHARDED | refusal | The write target (--target-driver vitess/planetscale) is a SHARDED keyspace and sluice is about to CREATE at least one table that does not yet exist there — the tables sluice creates carry no vindex, so nothing it then writes to them can route. Measured on a real 2-shard cluster (2026-08-14): without this door, CREATE TABLE SUCCEEDS and materializes the table physically on every shard, the vindex refusal (Error 1173) only fires at the first row write, and the error a real run actually surfaced was a nondeterministic vtgate schema-tracker race (Error 1105 "table not found" for a table just created) — a late, dirty failure leaving per-shard debris vtgate cannot route to. The refusal fires at the table-create phase (SchemaWriter.CreateTablesWithoutConstraints), naming the shards AND the specific new table(s); schema diff / preview do NOT refuse a sharded target. Since v0.127.0 (H-2) the door is PER-TABLE and existence-gated: if every in-scope table ALREADY exists on the keyspace — the pre-vindexed case — the CREATE TABLE IF NOT EXISTS phase no-ops and sluice streams rows into the existing, correctly-routed tables, so migrate / continuous sync into a pre-sharded, pre-vindexed keyspace is a SUPPORTED flow, not a refusal. An unsharded keyspace (a single - shard) passes; a shard-enumeration failure WARNs and proceeds (a transient probe error must not break a working target — the run then degrades to the measured late failure, never to silent loss); a per-table existence probe that itself errors is surfaced loudly rather than swallowed into a vindex-less CREATE. | Pre-create the named table(s) on the platform WITH their vindexes (your Vitess topology tooling / pscale), then re-run — the door passes once they exist and sluice streams rows into the routed tables (the supported pre-vindexed flow). Or point the target at an UNSHARDED keyspace (create one on the platform) if you want sluice itself to create the tables — its created tables carry no vindex, so it cannot populate a sharded keyspace's tables it made itself. |
SLUICE-E-SCHEMA-KEYSPACE-MISSING | refusal | The multi-database fan-out probed a Vitess/PlanetScale (vitess/planetscale) target and the derived keyspace/database does not exist. vtgate does not support CREATE DATABASE — keyspaces are provisioned through the platform, not SQL — so sluice cannot auto-create it the way it does on vanilla/MariaDB targets, and refuses before anything lands. The existence probe runs FIRST (a SHOW DATABASES scan on a server-level connection), so a keyspace that already exists satisfies the ensure step with nothing to create and the fan-out proceeds. (The v0.125.0 first cut of this guard refused unconditionally, which made its own printed remedy unrunnable — a fully pre-provisioned fan-out re-refused identically; Bug 249.) | Create the keyspace/database on the platform (e.g. pscale database create, or your Vitess topology tooling), then re-run — the existence probe passes once it is there. Or skip the fan-out and point per-database explicit --target DSNs at the keyspaces you already have. |
SLUICE-E-BACKUP-STORE-NAME-COLLISION | refusal | The destination store for backup full or backup export-as-parquet folds letter case — measured by a two-object probe at operation start, true of a local directory on Windows NTFS or macOS default filesystems, never of Linux filesystems or cloud object stores — and the source's table set carries names that differ only by case (Orders + orders, legal on case-sensitive MySQL and on PostgreSQL), whose table-derived store paths would fold to ONE host path. Without this refusal the later table's data silently overwrote the earlier's at exit 0 (serial table writes), leaving a backup that verify/restore later refuse as chunk corruption — damage written at backup time — while parallel table workers died on a spurious file-lock rename error (Bug 248). The probe runs only when the table set actually carries a case collision; its two objects live under their own fold-probe/ prefix and are deleted either way. | Back up to a case-sensitive store (a Linux filesystem path, or an object store such as S3), or --exclude-table one colliding table per named group, or rename one source table per group. Existing backups taken on a folding store from a case-colliding source predate this gate and are suspect: run backup verify — a chunk-corruption refusal on one of the colliding tables means that table's data was never in the archive; re-take the backup while the source exists. |
SLUICE-E-BACKUP-RECORDED-SCHEMA-MALFORMED | refusal | The chain's RECORDED SCHEMA carries an expression whose string literal never closes — a CHECK constraint, generated column, expression default, functional index or DOMAIN CHECK read from a MySQL-family source by a sluice older than v0.120.0, whose reader mangled any expression literal carrying an apostrophe (the escaping defect fixed in v0.120.0). Emitting that recorded DDL is what previously failed restore mid-run with the target's raw parse error (MySQL Error 1064) after earlier tables had already been created, while backup verify passed the same chain (Bug 243). Raised at every refusal door: backup verify at every depth (the schema rides in the manifest, so no chunk read is needed); restore / chain restore — including each incremental's schema deltas — BEFORE any DDL is emitted, leaving the target untouched; and backup incremental / backup compact / backup prune WARN — never refuse — when extending or maintaining such a chain, because their own work is valid and refusing would stop an operation an operator may still want, but the chain will not restore until re-recorded. The check has two arms. The structural arm proves the recorded expression cannot be emitted as valid SQL at all. The doubled-backslash arm refuses a structurally-valid recording from the same era whose string literals carry a backslash: the old reader kept MySQL's doubled spelling ('a\\d' meaning one backslash), which every current target now mis-reads — PostgreSQL and SQLite read the doubled form as two characters, and the MySQL emit boundary assumes the bare spelling and re-doubles it — so the restored expression would silently enforce a different predicate. This arm is keyed on the manifest's recorded sluice version (a parseable version older than 0.120.0) and a MySQL-family source; a chain stamped dev by a from-source build sits outside the version key and is not gated. The broker's --reset-target-data cold start runs the same door before its destructive table drop. | Take a fresh backup full of the live source with sluice v0.120.0 or newer — the current reader records these expressions correctly, and the new chain restores. The old chain's DATA chunks are intact: if the source is gone, restore --exclude-table=<the named table> recovers every other table (the refusal names each affected table and field), and the named expression's intended text is usually recoverable by eye from the quoted recording for a manual re-create. Do not hand-edit a signed chain's manifest; the signature covers the recorded schema. |
SLUICE-E-BACKUP-CHAIN-UNREADABLE | refusal | sluice backup compact or sluice backup prune re-read the chain the way a RESTORE would — once BEFORE its destructive delete pass and once AFTER it — and could not. One guard serves both, because both are chain-SHAPING operations whose delete pass reasoned locally that files the catalog no longer names are orphans; that reasoning is true of the BYTES and false of the chain's IDENTITY. The chain-root manifest.json is not a spare copy of segment 0's manifest: ADR-0152 binds the chain CEK's wrap to it, and for a passphrase chain the Argon2id salt the restore side re-derives its KEK from is recorded ONLY there, so deleting it revokes readability for EVERY segment — including ones the operation never touched. That is Bug 214, where compact exited 0 with groups_merged=1 segments_removed=3 on a chain that then refused at unwrap chain cek holding a correct passphrase; prune deleted the same file whenever retention dropped segment 0, on a schedule rather than as occasional maintenance. The message names WHICH of the two stages refused and that distinction is the whole of what happens next: a pre-swap/pre-sweep refusal deleted NOTHING and leaves the chain exactly as restorable as it was, while a post-sweep refusal reports a chain the operation has already changed. The check is cheap — it walks the lineage, verifies every chunk file the surviving manifests reference is still PRESENT in the store (statted, not read), re-resolves the chain's identity and recorded key-derivation material, and (only when --encrypt supplied key material) performs the real chain-CEK unwrap; it never reads or decrypts chunk CONTENT, which stays sluice backup verify's job. One shape carries its OWN message inside this code (roadmap item 100). Retention is SEGMENT-granular: backup prune rounds --keep-incrementals and --keep-duration UP to the nearest segment boundary so it retires only WHOLE leading segments — keeping more than asked, never fewer — because trimming LEADING incrementals INSIDE the floor segment severs the chain (the segment's full stays anchored where it is while the first surviving incremental starts later, so the events between them are gone and the walk refuses on the severed parent link). When the chain has no segment boundary at or above the requested retention, rounding up would retain the whole chain and the run would delete nothing, so prune refuses rather than report a prune that freed no space. A NON-rotated (single-segment) chain has no boundary at all, so every --keep-incrementals prune of one refuses. That refusal names the shape, states that nothing was deleted, and lists the --keep-incrementals counts (if any) that land on a segment boundary on this particular chain. | Read the stage in the message first. pre-swap/pre-sweep: nothing was deleted and no catalog was written — fix what the message names and re-run. post-sweep: the deletes already happened and re-running cannot undo them; apply the recovery the message names — for a missing chain-root manifest that is cp <chain>/seg-merged-<id>/manifest.json <chain>/manifest.json, and because every rotation-born segment full carries the same chain salt, any surviving segment's manifest.json restores the chain's identity byte-exactly — or restore from another copy of the chain. Do not take further backups against a chain in this state until it reads again. Pass --encrypt with the chain's key material to compact/prune so the gate proves the chain's key still UNWRAPS rather than only that its identity survived; without it the run WARNs that it verified identity only. For the item-100 refusal the remedy is a retention this chain's segment boundaries can actually express: use one of the boundary keep-counts the message lists, or a --keep-duration cutoff older than every incremental in the chain (they are all retired and the newest segment's full remains as a self-contained restore base). A never-rotated chain has no segment boundary at all, so --keep-incrementals cannot express retention there — use --keep-duration, or leave the chain unpruned until it rotates. Either way that refusal deleted nothing. |
SLUICE-E-BACKFILL-NO-PRIMARY-KEY | refusal | sluice backfill refused the table: it has no primary key, or a primary-key column is non-orderable (JSON/array/geometry) or exists only on sluice's target-planning schema — so the keyset walk that bounds each UPDATE to --batch-size rows has nothing safe to cursor on. An unbounded whole-table UPDATE is exactly the statement-time-wall / long-lock shape the command exists to avoid, so sluice refuses rather than degrade to it. | Add a primary key to the table (or fix the non-orderable key), then re-run. There is no flag to force an unbounded backfill — for a small table where a single UPDATE is fine, run it directly in your SQL client. |
SLUICE-E-BACKFILL-UNSUPPORTED-ENGINE | refusal | The --driver engine does not implement the in-place backfill surface. Backfill ships for MySQL (including the planetscale / vitess flavors, which ride the same bounded-UPDATE path) and Postgres; SQLite/D1 have no backfill executor yet. | Pass --driver mysql, --driver planetscale, --driver vitess, or --driver postgres. For SQLite/D1, run the transform directly — a single-file/edge database doesn't need the online-safety machinery. |
SLUICE-E-BACKFILL-UNKNOWN-COLUMN | refusal | A --set clause names a column that does not exist on the target table, caught against the read schema before any UPDATE runs (the message lists the table's actual columns). Refusing up front beats a per-chunk SQL error — or worse, a typo silently creating the impression the backfill ran. | Fix the --set column spelling (the left side of the first =); expressions on the right side are passed to the engine verbatim and are validated by the database itself. |
SLUICE-E-BACKFILL-INCOMPLETE | runtime | sluice backfill --verify (or --verify-only) counted rows still matching the --where guard AFTER the walk completed — the check ran truthfully and found unfinished work, the online-backfill catch-up signal: rows inserted behind the walk's cursor during the run, or written since a completed run. The walk's own work is intact and persisted (the migration state stays complete); the gate is saying the table is not yet safe for the contract step. | Re-run the backfill to pick up the stragglers (a spec whose stored state is complete needs --restart to walk again), then verify again. On a quiesced database, a nonzero count after a clean walk means the --where guard does not actually self-describe doneness — fix the predicate (e.g. new_col IS NULL) so an already-backfilled row no longer matches. |
SLUICE-E-BACKFILL-CORRUPT-CURSOR | refusal | sluice backfill refused to resume: the persisted cursor for this spec was written by an older sluice whose JSON state store mangled non-string-safe PK values — binary cursor bytes had invalid-UTF-8 sequences replaced with U+FFFD, and integer cursors above 253 drifted through float64 — so its stored value provably no longer names the row the walk stopped at. Resuming from it would silently skip (or replay far beyond the one-chunk bound) arbitrary PK ranges, and a later contract step would destroy the never-backfilled rows; refusing loudly is the only safe answer. Current releases store cursors in a lossless tagged envelope, so fresh runs never produce this. | Re-run the same spec with --restart to walk the table from the beginning. With a self-describing --where guard (e.g. new_col IS NULL) the re-walk updates only the rows the interrupted run never reached — already-done rows are untouched. |
SLUICE-E-BACKFILL-CONCURRENT-RUN | refusal | sluice backfill refused to start: the spec's state row is still in the walking phase AND its heartbeat (the row's updated_at, touched on every committed chunk) is fresher than the 5-minute freshness window — another run of the same spec looks live, typically an overlapping cron invocation. Two concurrent walks of one spec interleave cursor writes, breaking the at-most-one-chunk replay bound into arbitrary skipped or replayed PK ranges, so sluice refuses before touching anything (including a --restart, which would clear the state row out from under the live walker). Heartbeat-only, no lease: a single chunk UPDATE outliving the window can slip past the guard, and a kill -9'd run keeps the spec refused for at most one window. | Wait for the running backfill to finish (or for its heartbeat to go stale — the window passes with no committed chunk), then re-run; the second invocation resumes from the persisted cursor. If no other run exists (e.g. the previous one was killed seconds ago), simply wait out the window. Deep client↔database clock skew can also trip the guard — it only ever over-refuses, never lets a concurrent walk through. |
SLUICE-E-PS-FK-NOT-ENABLED | refusal | sluice migrate (or a sync cold-start) refused before the copy: the PlanetScale target has foreign-key support disabled — allow_foreign_key_constraints is off, read back as foreign_keys_enabled=false — while the source schema declares foreign keys the run adds AFTER the copy. With it off the platform rejects every ADD FOREIGN KEY outright (and there is no deploy-request fallback for FK constraints), so the run would fail at the constraints phase after the whole copy, and --resume re-hits the identical wall. Refused up front so a seconds-long fix replaces an hours-then-fail. This is the highest-confidence branch of the PlanetScale foreign-key preflight (target is PlanetScale, the source has at least one foreign key, --skip-foreign-keys is not set); the lower-confidence risks are a WARN, not this refusal — FK support ENABLED but the branch has safe migrations ON (which blocks the direct DDL sluice's FK add uses, errno 1105, with no FK-constraint deploy-request fallback) fires an advisory before the copy but never refuses, and when no PlanetScale service token is supplied sluice cannot verify the setting and warns instead. | Enable foreign key support on the target database (PlanetScale dashboard → the database's Settings → "Allow foreign key constraints", or PATCH the database's allow_foreign_key_constraints via the PlanetScale API), then re-run — or re-run with --skip-foreign-keys to migrate WITHOUT the foreign keys, which keeps each FK's referencing columns indexed so you can add the constraints out-of-band afterward. |
SLUICE-E-PS-SAFE-MIGRATIONS-DISABLED | refusal | sluice expand-contract (or sluice deploy-ddl) refused: the PlanetScale production branch does not have safe migrations enabled, and deploy requests — the mechanism these commands ship schema changes through — cannot be created into such a branch. sluice never enables the toggle for you: it is a behavior change on your production branch (direct DDL becomes blocked from then on, and the enable/disable propagation lag makes toggling around a run unsafe). (migrate's ADR-0148 index-build fallback never raises this code — with safe migrations off it simply stands down and the direct index error surfaces with its usual hint.) | Enable the branch's "Safe migrations" setting in the PlanetScale UI (or pscale branch safe-migrations enable <db> <branch> --org <org>), understanding that every future schema change on the branch must then ship via a deploy request; then re-run. |
SLUICE-E-PS-DEPLOY-REQUEST-FAILED | runtime | A PlanetScale deploy request driven by sluice (sluice expand-contract, sluice deploy-ddl, or migrate's ADR-0148 index-build fallback) genuinely went wrong: it entered a terminal failure state (error, complete_error, cancelled, complete_cancel, complete_revert, complete_revert_error), was closed without deploying, computed an empty diff (no_changes — that leg's DDL is likely already deployed from an earlier run), or computed a diff touching an object the leg never intended (the ADR-0167 pre-deploy blast-radius check: a stranger table in the diff means the dev branch's base was stale or the branch was edited outside sluice — deploying would ship those changes; deploy-ddl carries no intended set, so this check applies to expand-contract and the index fallback). The message names the leg, the deploy-request number, the state it saw, and the deploy request's URL. A deploy sluice merely stopped waiting on — a healthy deploy that outran a timeout, or one parked on a human gate — is the separate, non-failure SLUICE-E-PS-DEPLOY-REQUEST-INCOMPLETE. | Inspect the deploy request at the URL in the message. A no_changes diff means that leg's DDL is already deployed: delete the leftover dev branch and resume past the leg. A stranger object in the diff means the dev branch's base moved — delete the branch and re-run so it is re-provisioned from current production. For migrate's index-build fallback, recovery is always --resume: the index phase re-probes the target and rebuilds only what is still missing, so an index that did land is simply detected and skipped. |
SLUICE-E-PS-DEPLOY-REQUEST-INCOMPLETE | runtime | A PlanetScale deploy request sluice was waiting on did not reach a terminal state, and nothing failed — this code exists precisely so a healthy deploy is not reported as a failure. Three shapes: (a) a wall-clock bound was hit while the deploy was still running normally (the message carries the observed deployment_state, the progress percentage, PlanetScale's own ETA, and whether the operation has been throttled) — note that migrate's index-build fallback defaults --planetscale-deploy-timeout to 0, meaning wait indefinitely, so this shape only appears if you set a bound; (b) the request never became deployable within the deployable-wait bound, which on a database that requires administrator approval means it is waiting for a human (that wait is capped at 1 hour even when the deploy wait is unbounded — waiting forever on a person is indistinguishable from hanging); (c) the deployment is parked in pending_cutover with auto_cutover off — the schema build finished but PlanetScale will not apply it until a person confirms the cutover, and sluice never confirms one on your behalf. In every shape the deployment is still live in PlanetScale and its dev branch must be left alone until the deployment finishes: the running deployment depends on that branch (PlanetScale refuses the delete while it runs), and the recovery below needs the deployment to complete. | Watch the deploy request finish at the URL in the message — do not delete its dev branch until it has. For shape (a), continue afterwards with --resume (migrate) or --resume-from (expand-contract); to avoid the bound entirely, pass --planetscale-deploy-timeout 0 (or --deploy-timeout 0) and sluice will wait for the deployment however long it takes, narrating progress, ETA and throttling as it goes. A large index build legitimately runs for hours and replication-lag throttling is normal — it shows up as an ETA that stops converging, not as a failure. For shape (b), approve the deploy request and deploy it from the PlanetScale UI (approval alone deploys nothing — sluice never enables auto-apply), then resume. For shape (c), confirm the cutover in the PlanetScale UI, then resume. Once the deployment is finished, delete the leftover dev branch with pscale branch delete <db> <branch> --org <org> as the message spells out. |
SLUICE-E-PS-BRANCH-STALE-BASE | runtime | A newly created PlanetScale dev branch's schema can lag the production branch it was created from (observed live: a branch created 14 minutes after a deploy still lacked the deployed column — the lag is intermittent and its timing undocumented). A deploy request from such a branch would silently revert the missing changes (on the contract leg, that would drop the freshly backfilled expand column). sluice expand-contract, sluice deploy-ddl, and migrate's ADR-0148 index-build fallback compare every dev branch's schema against production before applying any DDL, self-heal a stale base once (delete the branch → take an on-demand backup → recreate), and raise this error only when the branch is still stale after the rebase, or the rebase backup itself failed / outran --deploy-timeout. The same code also fires from the ADR-0167 post-wait freshness recheck: when a deploy request sat in its deployable/review wait for more than ~2 minutes and production's schema CHANGED in that window, the request's diff was computed against the old schema, so deploying it could silently revert the newer change — sluice refuses right before the deploy call instead. | If the rebase backup was still running, let it finish in PlanetScale and re-run. Otherwise compare pscale branch schema <db> <branch> against the production branch to see what differs, take a fresh manual backup of production (pscale backup create), and re-run. For the post-wait recheck shape, simply re-run — the command re-provisions the dev branch from current production and recomputes the deploy request. |
SLUICE-E-PS-DEV-BRANCH-NOT-ADOPTABLE | refusal | A re-run found the dev branch an earlier attempt left behind — the branch name is derived deterministically from the leg's DDL, so finding one means a previous run intended byte-identical DDL — and could not adopt its deploy request. Adoption is the normal outcome and raises no error: a deploy request that is already deploying is joined by the same poller a fresh run uses (progress narration, fast-fail on a terminal state, keep-the-branch rule), and one that already deployed is finalized and the branch cleaned up. This code is the residue, and the message names which shape it hit: (a) the branch has no deploy request at all; (b) it has more than one, so sluice will not guess; (c) the deploy request merges into a different branch than this run targets; (d) the deploy request was never deployed — sluice adopts only an already-issued deploy, because the guarantees it makes before a deploy (that the dev branch's schema base still matches current production, and that the request's diff touches nothing outside the intended tables) cannot be re-established for a branch it did not provision in this run, and deploying from a stale base silently reverts newer production schema; (e) the deploy request ended in a terminal failure state; (f) the deploy request reports a deployment_state sluice does not recognize while PlanetScale still reports it deployable — an unknown state cannot tell sluice whether a deployment is running, so it refuses without claiming anything about what is. The message tells you to delete the dev branch only in the shapes where nothing is deploying — the unconditional "delete the branch and re-run" this refusal replaces would, followed mid-build, have discarded hours of completed index build. | Follow the shape the message names. For (a), (d) and (e) nothing is in flight, so the branch is safe to delete — the message spells out pscale branch delete <db> <branch> --org <org> — and re-running re-provisions it from current production. For (b), (c) and (f), open the deploy request(s) in PlanetScale and see what they are doing before touching the branch: close or delete the ones that do not belong to this run, then re-run. If the message instead says sluice could not enumerate the database's deploy requests, do not delete the branch: re-run once the control plane answers, because an un-enumerated deploy request may be deploying right now. |
SLUICE-E-PS-DIRECT-DDL-BLOCKED | refusal | The PlanetScale branch has safe migrations enabled, which refuses every direct DDL statement (Error 1105 "direct DDL is disabled") — and sluice needed one. Two cases, each named in the message: (a) creating (or column-migrating) one of sluice's own control tables — sluice_migrate_state, sluice_cdc_state, and siblings — where the ensure paths are detect-first, so this fires only when the DDL is genuinely needed and the message echoes the exact refused statement; (b) a user-table CREATE during migrate's or sync cold-start's schema-apply phase — a fresh migrate into a safe-migrations branch always refuses here, before any data moves (the ADR-0148 index-build fallback engages later, at the index phase, and cannot help with table creation). | Case (a): bootstrap the control tables through the governed channel — sluice control-tables ddl prints the exact CREATE statements, sluice deploy-ddl --org <org> --database <db> --ddl '<statement>' ships each one via a deploy request; then re-run (a column-migration ALTER ships the echoed statement the same way). Case (b): disable safe migrations on the branch for the migration window and re-enable it after, or pre-create the schema via deploy requests (sluice schema preview prints the target DDL, sluice deploy-ddl ships each statement) — a fresh re-run of sluice migrate then skips the pre-created tables whose column shape matches (ADR-0166), while a --resume re-run skips the create-tables phase outright once every in-scope table is recorded complete (the ADR-0166 shape gate deliberately does not run on resume), and a sync stream skips schema-apply with sluice sync start --schema-already-applied. |
SLUICE-E-SOURCE-FOREIGN-DUMP | refusal | The --source file is a plain mysqldump / pg_dump .sql dump or a pg_dump custom-format (PGDMP) archive — full-dialect / private formats sluice deliberately does not parse (the IR-first tenet; docs/research/flat-file-sources.md). Detected by content signature at open, on any file-reading source driver (sqlite, mydumper, csv/tsv/ndjson), before any data moves — previously the sqlite dump-materializer would die mid-stream on a confusing SQL error. | Restore the dump to a scratch server with its native tool and migrate live — the error message carries the exact three-command recipe (docker run a scratch MySQL/PostgreSQL, mysql/psql/pg_restore the dump into it, sluice migrate from the scratch server). A mydumper/pscale database dump DIRECTORY needs no scratch server: --source-driver mydumper. |
SLUICE-E-SOURCE-REPLICA-IDENTITY | refusal | A Postgres-source sluice sync refused at cold start, before scoping its publication, because an in-scope source table has no usable replica identity. Four shapes fall out of one catalog read: the table's only key is a DEFERRABLE PRIMARY KEY (Postgres skips every non-immediate index when it resolves a replica identity — the same pg_index.indimmediate bit as SLUICE-E-TARGET-DEFERRABLE-KEY, at the other end of the pipeline); the table has no key at all under REPLICA IDENTITY DEFAULT, which resolves to the primary key and nothing else; REPLICA IDENTITY is explicitly NOTHING; or — the fourth, added 2026-08-08 — the identity includes a STORED generated column, which Postgres does not publish before 18 (see SLUICE-E-CDC-GENERATED-PRIMARY-KEY for what that costs on the apply side). Adding such a table to a publication with pubupdate/pubdelete makes Postgres reject the source application's own writes to it, and the same for DELETE. The wording differs by shape and the refusal quotes the right one (Bug 235 — it previously quoted the first for both, so an operator grepping their Postgres log for it found nothing): for a deferrable or missing key, ERROR: cannot update table "x" because it does not have a replica identity and publishes updates; for a generated identity column, on Postgres 18+, ERROR: cannot update table "x" with DETAIL: Replica identity must not contain unpublished generated columns.. INSERT keeps working, so the table looks healthy until the first update and the breakage surfaces in the application, where nothing points back at sluice. sluice refuses before the CREATE/ALTER PUBLICATION runs, so the source is left untouched. REPLICA IDENTITY FULL is never refused for the first three shapes, including on a keyless table — but it is not a remedy for the generated-column one, because Postgres leaves a generated column unpublished under FULL as well; the refusal says so per table. Note the asymmetry with the target-side code: an immediate UNIQUE index alongside a deferrable primary key rescues the TARGET (ON CONFLICT can arbitrate on any unique index) but NOT the source, because REPLICA IDENTITY DEFAULT never looks past the primary key — the refusal names that index and the one-line USING INDEX fix. | Fix each named table on the SOURCE, then re-run. Either make its key immediate (ALTER TABLE t DROP CONSTRAINT t_pkey; ALTER TABLE t ADD CONSTRAINT t_pkey PRIMARY KEY (...); — NOT DEFERRABLE is the default), or give it a replica identity: ALTER TABLE t REPLICA IDENTITY FULL; works for a deferrable or missing key (it publishes the whole old row, at the cost of WAL volume on every UPDATE/DELETE) but not for a generated identity column, and ALTER TABLE t REPLICA IDENTITY USING INDEX idx; is the narrower fix when the table already carries an immediate NOT NULL UNIQUE index — the refusal names it when one exists, and for the generated-column shape it is the only one of the two that helps (the refusal only offers an index that carries no generated column). The structured hint= field is per shape as of Bug 235, so it prescribes only what applies to the tables actually named: a generated-identity run is never told to try FULL. Otherwise take the table out of scope with --exclude-table. There is no flag to proceed anyway: proceeding is what breaks the operator's own application. |
SLUICE-E-SOURCE-WRONG-DRIVER | refusal | The --source is a recognisable input this source driver does not read: a mydumper directory handed to csv/sqlite, a CSV/TSV/NDJSON file handed to mydumper/sqlite, a binary SQLite .db handed to csv, a gzip/zstd-compressed file, a UTF-16 text file, a single-array JSON document handed to ndjson, or a .tsv/.csv extension contradicting the chosen delimited driver. Refused loudly instead of mis-parsing (a tab file through the comma lexer would silently stage one wide column). | The message names the right --source-driver or the preparation step (decompress, transcode to UTF-8, jq -c '.[]' for a JSON array). For a correctly-delimited file whose extension merely lies, declare intent with --source-driver csv --csv-delimiter=.... |
SLUICE-E-CSV-NULL-AMBIGUOUS | refusal | A csv/tsv source contains an UNQUOTED empty field and no NULL representation was declared. RFC 4180 has no NULL — NULL-vs-empty-string is pure producer convention and the #1 silent-loss class for CSV ingest — so sluice refuses to guess, naming the record and column. A QUOTED empty field ("") is unambiguously the empty string and never triggers this. | Declare the file's convention: --csv-null='' (an unquoted empty field means NULL — the PostgreSQL COPY CSV convention), or --csv-null='\N' / --csv-null=NULL (that literal means NULL; empty fields are then empty strings). A quoted field is always data regardless. |
SLUICE-E-CSV-HEADER-UNDECLARED | refusal | A csv/tsv source was opened without --csv-header or --csv-no-header. Header presence is never sniffed: guessing wrong either silently eats the first data row as column names or turns the header into a data row. | Pass --csv-header (the first record carries the column names) or --csv-no-header (columns are named col1..colN in file order). |
SLUICE-E-EXPORT-UNREPRESENTABLE | refusal | sluice backup export-as-parquet refused a column type or value that has no faithful Parquet representation: a multi-dimensional array value (the column type declares no dimensionality, so the derived LIST<element> schema cannot hold nested lists), a TIME value outside a calendar day (MySQL TIME durations reach ±838h; PG allows 24:00:00), a PG NUMERIC NaN/Infinity (Parquet DECIMAL has no non-finite form), a decimal with more digits than its declared precision/scale, or a timestamp with sub-microsecond precision. The export never silently narrows, rounds, or wraps a value — the documented string downgrades (unbounded NUMERIC, TIMETZ) carry the exact value text and are WARNs, not this refusal. | Exclude the affected table (--exclude-table) and export the rest, or query that table's JSON-Lines chunks directly (DuckDB reads them natively — see docs/cookbook/duckdb-on-sluice-backups.md). |
SLUICE-E-WHERE-CDC-UNSUPPORTED-PREDICATE | refusal | sluice sync with a --where TABLE=<predicate> filter (ADR-0173 Phase 2) refused at sync-start: unlike migrate (which pushes the predicate down to the source read), a continuous filtered sync evaluates the predicate CLIENT-SIDE per CDC change (a binlog / logical-replication stream has no server-side row filter), so it accepts only a restricted, faithfully-evaluable grammar (a column compared to a literal with = != <> < <= > >=, col IN (…), IS [NOT] NULL, combined with AND/OR/NOT and parentheses). Case- and accent-insensitive MySQL-family collations (*_ci/*_ai) ARE accepted and faithfully evaluated using MySQL's own comparator (ADR-0174 Piece 1), as are Postgres deterministic named collations. What is still refused UP FRONT: a function call, a subquery, arithmetic, LIKE, an unknown column, string ORDERING (< <= > >=), equality (= != IN) on a FLOAT/DOUBLE column (the client compares the literal exactly while the source coerces it to an IEEE-754 double, so a high-precision literal can diverge — float ordering < <= > >= IS allowed and is compared as float64 to match the source's coercion — except a single-precision FLOAT ordering term on a PAD-SPACE-collation-forced table on a PlanetScale/Vitess (VStream) source, which IS refused: that table takes the A0 client-copy fallback, whose keep predicate runs on the cold-start COPY row, and the COPY carrier display-rounds single-precision FLOAT (the exact re-read repair runs after copy), so a boundary compare could silently drop a source-in-scope row; DOUBLE is full-precision in the carrier and exempt), a timezone-aware temporal comparison, a string collation sluice cannot reproduce faithfully (a non-UTF-8 charset collation, an unrecognized collation, or a Postgres NON-deterministic ICU collation whose = is collation-aware), a ci/ai comparison when --where-strict-collation is set, or unrecognized syntax. Evaluating any of these could silently leak out-of-scope rows or drop in-scope ones. (A PAD-SPACE-collation string filter on a PlanetScale/Vitess source — where the VStream server-side filter is NO-PAD — is NOT refused: those tables stream unfiltered server-side and are filtered client-side with the PAD-faithful comparator.) migrate --where is unaffected (it never evaluates client-side). | Rewrite the predicate within the supported grammar (equality / IN / IS NULL on a column is the common case — "country IN ('US','CA')"; a ci/ai collation now works directly), or, if you only need the initial subset and not continuous filtering, use sluice migrate --where (source-evaluated, full source-SQL). For a float boundary use an ordering comparison / range instead of = (and on a PlanetScale/Vitess PAD-SPACE-forced table use a DOUBLE column, or filter on a non-FLOAT column, since a single-precision FLOAT ordering term there is refused). If the column's collation is genuinely unreproducible (non-UTF-8 charset, PG non-deterministic ICU), normalize on the source and filter on that. |
SLUICE-E-WHERE-CDC-BEFORE-IMAGE | refusal | sluice sync with a --where filter (ADR-0173 Phase 2) refused at sync-start because the source is not configured to deliver full row BEFORE-images, which the filter's row-move evaluation requires: an UPDATE that changes a row so it newly matches (move-IN) or no longer matches (move-OUT) the predicate must be translated to a target INSERT or DELETE, and deciding that needs the predicate evaluated on BOTH the before- and after-image. On MySQL this is binlog_row_image=FULL (already required for all sluice CDC — a partial image raises SLUICE-E-CDC-ROW-IMAGE-PARTIAL); on Postgres each filtered table needs REPLICA IDENTITY FULL (the default REPLICA IDENTITY DEFAULT carries only the primary key in the before-image, so a predicate on a non-key column could not be evaluated on the old row). The message names the table and the exact remedy. | Postgres: ALTER TABLE <table> REPLICA IDENTITY FULL on each filtered table, then restart the sync. MySQL: SET GLOBAL binlog_row_image=FULL (see SLUICE-E-CDC-ROW-IMAGE-PARTIAL). |
SLUICE-E-WHERE-CDC-AFTER-IMAGE | refusal | sluice sync with a --where filter stopped MID-STREAM because an UPDATE on a filtered table arrived with an AFTER-image missing a column the predicate references, so the row-move evaluation cannot decide whether the row left the filter's scope — evaluating over the missing column would read it as NULL and could emit a spurious DELETE for a row still in scope at the source (silent target-side loss). This is the after-image sibling of SLUICE-E-WHERE-CDC-BEFORE-IMAGE and exists as a belt for the unchanged-TOAST class (audit 2026-07-23 D0-1): pgoutput omits an out-of-line TOASTed column from the new tuple when a sibling column changed, and the Postgres reader backfills it from the REPLICA IDENTITY FULL before-image — so on a correctly configured source this code should be unreachable, and it firing means an unexpected partial after-image slipped past every reader-side guarantee. | Ensure the source delivers full row images for the filtered table (MySQL binlog_row_image=FULL, PG REPLICA IDENTITY FULL), then restart the sync. If the source is a Postgres logical stream and the images are configured FULL, this shape should be impossible — report it as a sluice bug with the logged table and column. |
SLUICE-E-WHERE-PUSHDOWN-DRIFT | refusal | sluice sync refused a WARM RESUME because the current --where flags don't match the row-filter subset this stream pushed into its Postgres publication at cold start (ADR-0176; the pushed subset's canonical hash is recorded in the target's sluice_cdc_state.row_filter_hash, publication_name's sibling). The publication row filter is DURABLE source-side catalog state that a warm resume deliberately never re-ensures — so resuming with a widened, changed, or removed --where would leave the SERVER silently filtering on the stale predicate: rows the new flags admit are never decoded or sent, the client-side evaluator can't see what the server withheld, and the stream under-delivers forever with sync status green (audit 2026-07-23 D0-2). The message names the stream, the recorded and current hashes, and the currently-pushed tables. | Three escapes: (1) re-run with the exact --where the stream was established with (the record then matches and the resume proceeds); (2) --restart-from-scratch to force a fresh cold start — it re-snapshots under the NEW predicate and re-ensures the publication filter (required for a widened filter anyway: rows the old filter excluded were never snapshotted); note that on a PG source the restart first hits the loud slot-exists refusal, which names its own remedy — drop the stream's replication slot (SELECT pg_drop_replication_slot('sluice_…')) and re-run; (3) --reset-target-data for the destructive variant that also clears the control row and target tables. A brand-new stream-id with its own --publication-name is always available for a side-by-side cutover. |
SLUICE-E-WHERE-FK-ORPHAN | refusal | sluice migrate with a --where TABLE=<predicate> row filter (ADR-0173) refused at the constraints phase: the filter excluded rows of a PARENT table, orphaning rows on a child table that references it, so the deferred ADD CONSTRAINT FOREIGN KEY failed with SQLSTATE 23503 on the target. The message names the child table, the FK constraint, and the referenced parent (flagging which side carries a --where). This fires only when --allow-degraded-fks is NOT set — with that flag the FK is instead attached NOT VALID (a PG target) and no refusal is raised. | Filter consistently so the referenced parent's rows are also copied (a child --where must not out-scope its parent), or pass --allow-degraded-fks (PG target) to attach the FK as NOT VALID and validate later after reconciling the orphans. Referential-aware auto-inclusion of parent rows is a future enhancement (ADR-0173). |
SLUICE-E-WHERE-UNKNOWN-TABLE | refusal | sluice migrate or sluice verify with a --where TABLE=<predicate> row filter (ADR-0173) refused at start: the TABLE key names no table in the source schema (after --include-table/--exclude-table scoping). The readers look the predicate up by exact table name, so a typo (--where user=... missing the s) or a case-fold mismatch (--where Users=... against a lower-cased PG relname) would silently find no predicate, drop the WHERE, and copy/count the WHOLE table — an out-of-scope over-copy that verify (riding the same lookup) would then confirm as a false PASS. sluice refuses up front instead, matching sync start --where's existing sync-start refusal for the same class and --map's unknown-table rejection. Matching is case-insensitive: a correctly-named key in any casing is accepted and canonicalized to the schema's own casing. | Correct the table name in the --where value (case does not matter; the name must exist in the source schema after any --include-table/--exclude-table scoping), or remove the entry. Pass the SAME corrected --where values to migrate and verify. |
SLUICE-E-SCHEMA-IDENTIFIER-INVALID | refusal | A schema value bound for one of the DDL positions that take a BARE, unquotable identifier — an index access method (USING <am>), an index operator class, a sequence data type (AS <type>), an RLS policy command (FOR <command>), or a MySQL charset/collation — is not a bare identifier (or, for the keyword positions, not one of the accepted values). Everywhere else a name is quoted and a hostile value is inert; at these positions it is not, and because sluice runs DDL with no bind parameters the driver uses the simple protocol, where a ; in the value starts a second statement. Real values (btree, ivfflat, hnsw, gin_trgm_ops, utf8mb4_0900_ai_ci) always pass, so this fires on a hand-edited schema, a foreign catalog, or a tampered backup manifest. | Correct the value at the named object in the source schema (or the backup manifest it was read from) and re-run; there is deliberately no flag to emit it anyway. If it came from a backup, treat the manifest as suspect — sign chains (--sign/--sign-key plus --require-signature) so a schema edit is caught at verify time rather than at DDL time. |
SLUICE-E-SCHEMA-IDENTIFIER-TOO-LONG | refusal | An identifier sluice would emit on the PostgreSQL target — a table, column, index, primary-key/foreign-key/check constraint name, or a synthesized <table>_<column>_enum type name — is longer than PostgreSQL's NAMEDATALEN-1 ceiling of 63 bytes (bytes, not runes: a multibyte name of 40 characters can exceed it). PostgreSQL does not reject an over-length identifier; it TRUNCATES it silently at CREATE time, so two names sharing their first 63 bytes become one catalog object. Because sluice emits the IF NOT EXISTS form for indexes and tables alike, that collision is a silent no-op rather than an error: for an index the target is left missing an index the source declared, and for a TABLE the second CREATE TABLE resolves onto the first relation and the bulk COPY that follows — whose target name truncates identically — lands the second table's rows INSIDE the first, exit 0. Sources with no length limit of their own (SQLite/D1) can hand the writer exactly that pair, so the refusal fires at emit time, before anything is created or copied. The message names the object kind, the offending name, its byte length, and the owning table. | Rename the source object so its emitted PostgreSQL name fits 63 bytes (for an index, note sluice may prepend <table>_ for cross-table disambiguation, so the source name has less than 63 bytes of headroom). sluice deliberately does not auto-truncate or auto-rename — either would silently reshape the operator's schema. |
SLUICE-E-SCHEMA-INDEX-NAME-COLLISION | refusal | Two distinct source indexes resolve to the SAME index identifier on a target whose index names are schema-scoped — PostgreSQL and SQLite. MySQL's are table-scoped, so a MySQL target cannot hit this and is not checked. On PostgreSQL, sluice prefixes <table>_ when a source index name is not already table-scoped, and that transformation is not injective: index user_id on table posts and index posts_user_id on the same table both render posts_user_id. MySQL auto-names a single-column index after its column, which makes the pair routine rather than exotic, and a PostgreSQL source reaches it too (table a index b_c versus table a_b index c). On SQLite there is no transformation at all, so the collision is the plainer one PostgreSQL's prefix disambiguates — two tables carrying an index of the same name — and SQLite compares identifiers case-insensitively, so a pair differing only in ASCII case collides too. Either way the index build emits CREATE INDEX IF NOT EXISTS — load-bearing for PostgreSQL's whole-phase reparent retry and for idempotent --resume — so the second build of a colliding name is a silent no-op: the target permanently keeps whichever index sorted first and loses the other. When the loser is the UNIQUE one, the target accepts duplicate rows the source rejects, with exit 0. Refused at schema-emit time, before any table is created or any row copied; the message names both source indexes, their tables, and the colliding effective name. | Rename one of the two source indexes so they no longer resolve to the same target name (e.g. ALTER TABLE posts RENAME INDEX user_id TO posts_user_id_idx on the source), then re-run. sluice will not auto-rename: an index whose name silently changed breaks ON CONFLICT ON CONSTRAINT, pg_dump diffs, and any application code naming it — and the operator needs to know the source carries two indexes sluice cannot tell apart on the target. |
SLUICE-E-SCHEMA-VIEW-NAME-COLLISION | refusal | A source view resolves to a name a table — or an earlier view — already occupies on a SQLite target. SQLite keeps tables and views in ONE namespace and compares identifiers case-insensitively for ASCII even inside double quotes, and sluice emits CREATE VIEW IF NOT EXISTS (load-bearing for an idempotent --resume), whose IF-NOT-EXISTS test asks whether a table-or-view of that name exists. So the CREATE returns OK and creates nothing: the view the source declared is simply absent on the target, at exit 0. Note the asymmetry that hid this — the INDEX-versus-table pair is LOUD on the same engine (there is already a table named a, with or without IF NOT EXISTS), and so is a view colliding with an INDEX (there is already an index named ix), so an operator who has seen sluice refuse a name collision reasonably assumes the class is covered. Only the view-versus-table/view pair is silent. This is a separate code from SLUICE-E-SCHEMA-INDEX-NAME-COLLISION on purpose: the object lost is a view, the namespace is the table/view one, and the remedy renames a different object. PostgreSQL and MySQL targets cannot hit it — both emit CREATE OR REPLACE VIEW, which has no no-op branch and raises loudly when the name is held by a base table. Refused before any data moves (and again at the view phase for the paths that skip the create-tables phase); the message names the view, the object it collides with, and that object's kind. | Rename the source view — or the table it collides with — so the two no longer resolve to the same SQLite name, then re-run. Check for a case-only difference first (Orders versus orders are one name on SQLite and two on a PostgreSQL source). If the view is not needed on the target, drop it on the source or exclude it. sluice will not auto-rename: a view whose name silently changed breaks every query naming it, and the operator needs to know the source carries two objects SQLite cannot tell apart. |
SLUICE-E-SCHEMA-TABLE-NAME-COLLISION | refusal | Two distinct source tables resolve to ONE table identifier on a SQLite target. SQLite compares object names case-insensitively for ASCII even inside double quotes, and sluice emits CREATE TABLE IF NOT EXISTS with a bare, never-schema-qualified name (load-bearing for an idempotent --resume) — so a PostgreSQL source legitimately holding public.orders and public."Orders" hands SQLite one name twice, and the second CREATE returns OK and creates nothing. This is the worst member of the IF NOT EXISTS silent-no-op family, and the reason is what happens next: nothing goes missing. The copy INSERTs the second table's rows under a name that folds to the FIRST table, so both tables' rows end up in one table, the row count is right for the surviving name, the run exits 0 with no failing statement and no warning. A later verify --depth count WOULD flag it — it compares each SOURCE table against the target, so both collide with the merged count — but nothing during the migration itself says anything anywhere in the run. Measured on the driver this engine uses: CREATE TABLE IF NOT EXISTS against a table (or a view) of that name is the silent no-op, the same statement WITHOUT IF NOT EXISTS is a loud table "a" already exists, and against an INDEX name it is loud with or without — so only the table-versus-table/view pair is silent, and the check deliberately does not walk index names. A separate code from the index and view collisions on purpose: the thing lost is rows, not an object, and the remedy renames a different object. MySQL targets (mysql/planetscale/vitess/mariadb) reach the identical loss by a second route: the same bare CREATE TABLE IF NOT EXISTS, on a server initialized with lower_case_table_names != 0 — the default on Windows and macOS servers, and a legitimate deliberate setting on Linux — where the second CREATE returns Note 1050, a warning, and creates nothing. Measured on real mysql:8 and mariadb:11.4: one table survives, and a row written under each spelling leaves both in it. That route is a property of the SERVER rather than of the schema — on the stock Linux default (lower_case_table_names=0) the same pair is two ordinary tables and nothing is refused — so sluice reads @@global.lower_case_table_names at each copy entry point and refuses only when the server actually folds, naming the setting's value alongside both tables. PostgreSQL targets reach the same shape only by 63-byte truncation, which is SLUICE-E-SCHEMA-IDENTIFIER-TOO-LONG. Refused before any data moves, at every copy entry point (migrate, both sync cold-start paths, add-table, both restore paths), and on MySQL again in the create-tables phase itself; the message names both source tables and the target identifier they share. | Rename one of the two source tables so they no longer resolve to the same target name, then re-run. Check for a case-only difference first — Orders and orders are two tables on a PostgreSQL source, one name on SQLite, and one name on a MySQL server that folds. If only one of the two is wanted on the target, exclude the other with --exclude-table. On a MySQL target the other way out is a server initialized with lower_case_table_names=0, where the two names are two tables — that setting is fixed when the data directory is initialized and cannot be changed on a running server. sluice will not auto-rename: a table whose name silently changed breaks every query naming it, and the operator needs to know the source carries two tables the target cannot tell apart. |
SLUICE-E-CONFIG-MULTI-NAMESPACE-TARGET-FLAT | refusal | A multi-namespace fan-out — --all-databases, --include-database/--include-schema, or --map-database/--map-schema, on migrate or on sync cold start — selected two or more source namespaces and was aimed at a target engine with a flat namespace (today: sqlite, the only flat-namespace engine that can be a target at all — d1 declares the same flat scope but is a migrate SOURCE only). Such a target can neither derive a per-namespace target DSN (CREATE DATABASE on MySQL, CREATE SCHEMA on PostgreSQL) nor accept a --target-schema override, so every selected source namespace would be written into ONE target namespace under bare, unqualified names. Two namespaces carrying a same-named table would SILENTLY MERGE — the second table's CREATE TABLE IF NOT EXISTS no-ops and its rows are INSERTed into the first table, at exit 0 — and even with no name collision at all the result cannot be routed back to a source namespace by verify, a later add-table, or CDC apply. sluice already refuses the explicit form of this (two --map-database sources aimed at one target name); a flat target is that same many-to-one for every namespace in scope. A fan-out that resolves to exactly ONE source namespace is allowed through — it is byte-identical to a plain single-namespace run — though a rename applied to it is cosmetic there, since a flat target carries no namespace name in any emitted identifier. | Run one source namespace per invocation, each with its own target (for a file target, its own file): sluice migrate --include-database app --target ./app.db, then --include-database billing --target ./billing.db. Or choose a target engine that namespaces — PostgreSQL (schemas) or MySQL (databases) — if the namespaces must land in one target server. |
SLUICE-E-VALUE-RAGGED-ARRAY | refusal | An array value bound for a PostgreSQL array column is not rectangular — some sub-array at a given nesting depth has a different length than its siblings ([[1,2],[3,4,5]]). PostgreSQL arrays are rectangular by definition: the wire form carries one length per dimension plus a flat row-major element list, so a jagged value has no faithful representation at all. Before this refusal existed the writer took every dimension's length from the FIRST sub-array at that depth and then appended every leaf it found, so a long row silently DROPPED its extra elements, a short first row silently dropped the tail of every later row, and a short TRAILING row panicked the writer with an index-out-of-range. The message names the column, the nesting depth, and the expected-vs-actual sub-array lengths. A Postgres source can never produce this (PG arrays are rectangular on the way out); the reachable producer is the pgtrigger change-payload path, whose to_jsonb() capture carries a JSON array that is under no rectangularity obligation. | Fix the source value so every sub-array at a given depth has the same length (pad the short rows, or model the column as jsonb, which stores jagged structure faithfully). If the value arrived through the trigger-CDC path from a genuinely rectangular source column, report it as a sluice bug with the logged column and depth. |
SLUICE-E-VALUE-TINYINT1-RANGE | refusal | A MySQL TINYINT(1) column — which sluice maps to boolean per MySQL's own BOOL/BOOLEAN convention — holds a value outside {0,1}. A TINYINT(1) is only a display width; the column physically stores the full signed 8-bit range, so a legacy column used as a small integer can hold 2, 5, 127, -1, and so on. The boolean mapping collapses every non-zero value to true, which would silently lose the integer. sluice refuses at the FIRST such value on every read path — the bulk-copy / snapshot reader, the binlog CDC reader, the VStream CDC + cold-start path, and the mydumper flat-file source — before the row is written, so no collapsed value ever reaches the target (any rows already copied held only genuine 0/1 and are correct). Earlier releases only WARNed here and carried the collapsed bool; a field report hit that as silent data loss on a TINYINT(1) column holding 0..6. | Change the source column's type away from TINYINT(1) (for example ALTER TABLE ... MODIFY <col> SMALLINT) so it is no longer read as a boolean — this works on every source, including PlanetScale/Vitess, where sluice reads the column type from the replication stream. For a bulk migrate from a non-Vitess MySQL source you can instead re-run with --type-override <table>.<col>=smallint (or =int), which re-types the column without touching the source (smallint is the safe floor — a tinyint override could re-emit a TINYINT(1) target that re-triggers the mapping on a round-trip); this override does not apply to a PlanetScale/Vitess source, whose boolean decision comes from the wire. A column that genuinely holds only 0 and 1 never triggers this. |
SLUICE-E-BACKUP-INTERRUPTED | refusal | A backup manifest records partial_state: in_progress — the sluice backup full run that wrote it was interrupted (killed, crashed, cancelled) or is still running right now, so its table list names only the tables that had finished at that moment. Refused before any data is read by restore (the single-manifest path and every link of the chain walk), by backup verify, and by export-as-parquet. Without the refusal each of those succeeded over a partial backup: a restore created every table in the manifest's embedded schema, loaded only the ones the manifest listed, logged the rest at INFO as restore: table not in manifest; skipping bulk-copy, and exited 0 — silent loss on the DR path — while backup verify rehashed every chunk the partial manifest listed, found them all intact, and reported the backup healthy. A manifest carrying NO partial_state at all is a Phase-1 (pre-v0.16.x) manifest rather than an interrupted one and is deliberately unaffected. Only backup full ever persists an in-progress manifest; incremental and backup stream rollover manifests are written only once complete, which is why this fires at a chain's root. | Finish the backup, then re-run the command: re-running the identical sluice backup full into the same destination RESUMES the interrupted run (tables already complete are kept, the rest re-stream) and flips the manifest to partial_state: complete. To abandon the partial run instead, sluice backup full --force-overwrite discards it and takes a fresh backup. There is no flag that restores a partial backup as though it were whole — the tables the run never reached are not in the store to restore, so the only honest outcomes are finishing it or restoring an older complete backup. sluice backup incremental already refuses to extend a chain off the same manifest, for the same reason. |
SLUICE-E-BACKUP-SCHEMA-DELTA-UNSUPPORTED | refusal | A chain restore or broker replay reached an alter_table schema delta whose shape has no faithful replay on the target. An incremental manifest records one delta per table whose shape changed during the window, and the replay side disposes of EVERY structural aspect of it: an added column, a column type change, a nullability change and an index create/drop/redefine are emitted through the engine's schema-delta surface; a column reorder and a DEFAULT change are proven not to need DDL and logged; a dropped column, a changed primary key, a column that gained or lost GENERATED, an ADDED or REDEFINED CHECK constraint, any foreign-key add/drop/redefine, and a malformed entry (before/after shapes naming different tables) refuse with this code. A DROPPED CHECK applies rather than refusing, because dropping a constraint only ever widens what is legal and so cannot reject a replayed event; adding one cannot be ordered faithfully at all, since the source's own ADD CHECK landed partway through the window and validated only the rows present at that instant. Previously the replay looked ONLY for ADDED columns and skipped every other shape in silence, so a mid-window ALTER TABLE orders ALTER COLUMN amount TYPE numeric(20,8) restored the column at its pre-window numeric(10,2), Postgres ROUNDED every replayed value on insert and returned success, and the restore exited 0 over silently truncated money. | Take a fresh full backup and start a new chain from it — the window's DDL has no faithful replay on the target, and refusing beats replaying values into a shape that disagrees with them. The message names the table and the aspect. For a dropped column specifically sluice deliberately does not auto-DROP on replay (the same window's change chunks can still carry pre-DDL events naming that column); apply the drop to the target yourself after restoring from a fresh chain if that is the intent. |
SLUICE-E-BACKFILL-VERIFY-NO-EVIDENCE | refusal | sluice backfill --verify counted zero rows still matching the --where guard and refused to authorize the contract step anyway, because the run it verified did no work. CountRemaining renders the SAME verbatim --where the chunk UPDATE does, so a predicate that is valid SQL but semantically wrong — a mistyped column, a coercion matching no row — counts 0 before the walk, updates 0 rows, and counts 0 after: every step agrees with every other step and not one of them is evidence a backfill happened. Three shapes refuse — the guard matched nothing before a fresh walk started, rows matched at the start and the walk updated none of them, or the spec's stored state was already complete and its stored progress row records no rows updated. Re-running a spec this release completed is NOT one of them: the completed run's row count is persisted and carried into the no-op, so the identical command exits 0 the second time. The third shape has one benign producer worth knowing — a spec completed by a sluice at or below v0.108.0, whose migrate-state codec dropped the row count when it wrote the completed entry, so the count reads back as 0 however many rows moved. Current releases persist it, but the count cannot be recovered from a row already stored without it. --verify-only never raises this code: it runs no walk and reads no control table, so it reports the count as the bare fact it is and says explicitly that it does not show a backfill ever ran. | Check that the --where guard selects the un-backfilled rows — run the count yourself (SELECT count(*) FROM <table> WHERE <guard>) and confirm it is nonzero — then re-run with --verify. If the backfill genuinely ran earlier (by hand, in a run whose control-table state is gone, or in a spec a sluice at or below v0.108.0 marked complete without recording the count), use --verify-only, which reports the remaining count without claiming the work happened; sluice expand-contract --resume-from contract rides that same verify-only gate. |
SLUICE-E-COPY-RETRY-AMBIGUOUS-KEYLESS | refusal | A cold copy hit a classified TRANSIENT target error (a PlanetScale/Vitess primary reparent, a Postgres storage auto-grow, a dropped connection) part-way through writing a batch, and the table it was writing has no PRIMARY KEY and no NOT NULL UNIQUE index. Both engines normally RIDE such a transient by re-sending the same rows on a fresh connection, and that is safe on a keyed table because the two possible prior outcomes are distinguishable after the fact: an attempt that rolled back re-applies cleanly, while an attempt that COMMITTED and then lost its acknowledgement collides on the key (MySQL Error 1062 / Postgres SQLSTATE 23505). With no unique key there is nothing to collide on, both outcomes look identical, and re-sending would silently double every row in the batch. sluice refuses rather than retry. The message names the table, the batch's row count, and the underlying transient. | Add a PRIMARY KEY or a NOT NULL UNIQUE index to the source table and re-run — that is the durable fix, and it also lets the table use the idempotent copy path. Failing that, re-run this table's copy against an EMPTY target (drop/truncate the target table first) so a partial write cannot be compounded, and prefer a quieter window: the refusal only fires when the target is genuinely mid-transient. |
SLUICE-E-CDC-SCHEMA-REPLAY-MISMATCH | refusal | A MySQL/MariaDB binlog row event's TABLE_MAP_EVENT column-type vector — the shape the event was RECORDED under — disagrees with the table's CURRENT information_schema shape, which is what sluice decodes values against. At the binlog head this cannot happen (the server serialises DDL against DML on one table, so a schema re-read after a DDL always sees the matching shape), so it means the stream is REPLAYING history recorded before a DDL that ran while sluice was not consuming — typically a warm resume after downtime. A DDL that changes the column COUNT already fails loudly on arity; this refusal is for the one that does not: a type change (or a rename that also retypes) leaves the count intact and every replayed value is decoded against the wrong type — the right number of columns carrying the wrong meaning. The message names the schema-qualified table, the column, the recorded binlog type and the current declared type. | Re-snapshot the affected table: the buffered binlog history predates a schema change, so there is no position from which it can be replayed faithfully. Restart the sync with --restart-from-scratch (or take the table out of scope, re-run, and bring it back with a fresh cold start). If the sync is expected to carry DDL forward, run the schema change through sluice deploy-ddl / the schema-forward path while the stream is LIVE rather than during downtime — the reader then records the change at its binlog position instead of discovering it after the fact. |
SLUICE-E-CDC-STATEMENT-DML. Through v0.136.0 that refusal quoted the offending statement's leading fragment and cut it at a token list that did not include comparison operators — so a WHERE ssn = '123-45-6789' put the value into a log line. v0.137.0 replaced the cut with an allowlist that keeps identifier material and stops at the first token that is not, so every literal form now falls outside it by construction; the sha256 prefix that used to identify the event went with it (a recomputable digest of a low-entropy withheld value is an oracle for it) and the binlog file, position, commit timestamp and GTID identify the event instead. Log lines emitted by earlier binaries may contain row data — treat that log history accordingly.