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-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 flags exist on migrate, restore, and sync start (the three modes that run the deferred index build) — not yet on the fleet sync run specs or the sync from-backup broker. | --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 flags exist on migrate, restore, and sync start, not yet on the fleet sync run specs or the sync from-backup broker. 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, or sync start) 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-CDC-REPLICATION-PERMISSION | runtime | The connecting role lacks the REPLICATION attribute. | ALTER ROLE x REPLICATION; see Prepare a Postgres source. |
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, pgbouncer) in front of the database: the pooler strips the replication=database startup parameter, so the replication-protocol command reaches a normal backend as plain SQL. Logical replication cannot run through a pooler. | 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-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 (all read at CDC start: sync cold-start, warm resume, and backup incremental; the binlog_row_value_options read is tolerant since the variable only exists on MySQL ≥ 8.0.3). Under a partial image the UPDATE before-image omits non-key columns and the after-image omits unchanged columns, so sluice's CDC would silently lose every UPDATE (stream green, counts equal, only content diverges). Azure Database for MySQL Flexible Server ships MINIMAL as its platform default; DO/RDS/GCP ship FULL. sluice refuses at CDC start instead. | Set 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: az mysql flexible-server parameter set --name binlog_row_image --value FULL. Segments already written under the partial mode stay partial — start the sync fresh after the flip. See managed-services. |
SLUICE-E-CDC-STANDBY-SOURCE | refusal | The CDC 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. 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). PG 16+ standbys can host logical slots, but slot creation blocks on the primary's next running-xacts record and managed platforms gate the nudge, 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 remains a fine source for bulk sluice migrate. See managed-services. |
SLUICE-E-CDC-MARIADB-UNSUPPORTED | refusal (retained, no longer emitted) | Historical refusal from before MariaDB CDC shipped. As of v0.99.271 (ADR-0170) sluice supports continuous CDC from a MariaDB source: it parses MariaDB's domain-based GTID positions (e.g. 0-100-38) and resumes off them, so this refusal is no longer raised — the code is kept in the registry for back-compat only. Native uuid/inet columns were briefly refused too (v0.99.271) and now decode faithfully as of v0.99.272 (ADR-0171); see the row below. | None needed — MariaDB CDC works, including native uuid/inet columns as of v0.99.272. |
SLUICE-E-CDC-MARIADB-NATIVE-TYPE-UNSUPPORTED | refusal (retained, no longer emitted) | Historical refusal from before MariaDB native uuid / inet6 / inet4 columns decoded through CDC. v0.99.271 (ADR-0170) raised it because the binlog carries these types' raw storage bytes (16 for uuid/inet6, 4 for inet4) — not the text bulk migrate sees — and a decoder written for MySQL VARCHAR would have stringified the raw bytes into a wrong value that a MySQL-family CHAR(36)/VARCHAR(45) target would silently accept (Postgres rejected it loudly, 22P02). As of v0.99.272 (ADR-0171) the faithful binlog decode shipped and this refusal is no longer raised — the correct byte layout is canonical big-endian UUID (no reorder), length-prefixed with trailing 0x00 stripped (width taken from the declared data_type), and BSD inet_ntop6 text; verified byte-exact on live 11.4/10.11. The code is kept in the registry for back-compat only. See the field note. | None needed — MariaDB native uuid/inet now stream through CDC faithfully. (Bulk sluice migrate was always unaffected.) |
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 — a pooler cannot proxy replication — 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-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. 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-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-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 non-finite floats), or an infinity/pre-Gregorian (BC) Postgres timestamp into a fixed-width target. 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 (e.g. NULLIF / CASE on the source query). |
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 is the current caller). | Re-run with --yes (or -y) to confirm the destructive operation. |
SLUICE-E-DRIVER-HOST-MISMATCH | refusal | The chosen driver cannot drive the DSN's host — today: the vanilla mysql driver pointed at a PlanetScale endpoint (*.connect.psdb.cloud), whose binlog CDC and LOAD DATA cold-copy Vitess blocks. Caught up front, before any connection. | Pass --source-driver planetscale / --target-driver planetscale for the PlanetScale endpoint. |
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. | 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. |
SLUICE-E-VSTREAM-FLOAT-LOSSY | refusal | backup full on a PlanetScale/Vitess (VStream) source with --strict-float, when a single-precision FLOAT column cannot be re-read exactly: the table is keyless / float-PK-only (no primary key to target the exact re-read — refused upfront), larger than --float-reread-max-rows (too large for the bounded-memory exact re-read), or the exact re-read returned rows but not one streamed row's primary key matched (a systemic PK-rendering divergence). 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 and you have the headroom, or drop --strict-float (the default archives exact where it can and rounded — 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. 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://...). |
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; (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. All three 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. 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). | 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. This is the loud, coded TWIN of SLUICE-E-BACKUP-SIGNATURE-INVALID for a backup that is ENCRYPTED but NOT SIGNED: without a signature, sluice catches the swap/tamper at decrypt rather than at verify, and refuses before any row lands. 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. If you also want tamper caught at backup verify time (before a restore), sign the chain (--sign / --sign-key) — an unsigned encrypted backup is protected only at decrypt. 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. | 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 | Another writer advanced this backup 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 catalog; two writers interleaving it (a duplicate cron entry running backup incremental twice, a backup racing a compact/prune, an operator double-start) can silently corrupt the chain's structure. The catalog write is a compare-and-swap on the chain's write-generation, 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). | 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 refused operation left the catalog untouched (a refused backup's own manifest is durable and 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. |
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 sync --backup-to 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-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) — 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. | 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-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) entered a failure state (error, complete_error, cancelled, …), was closed without deploying, computed an empty diff (no_changes — that leg's DDL is likely already deployed from an earlier run), 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), or did not become deployable / complete before --deploy-timeout. The message names the leg, the deploy-request number, the state it saw, and the deploy request's URL. | Inspect the deploy request at the URL in the message. A timeout on a large table usually means the VReplication deploy is still running — watch it finish, then continue with --resume-from migrate (after expand) or re-run the contract leg. A no_changes diff means that leg's DDL is already deployed: delete the leftover dev branch and resume past the leg. If your organization requires deploy-request approval, approve it and re-run — on that specific timeout sluice KEEPS the dev branch (deleting it would close the still-open deploy request); delete the branch yourself once the request closes, as the message spells out. 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 deployed after the timeout is simply detected and skipped. |
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-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. 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 re-run of sluice migrate then skips the pre-created tables whose column shape matches (ADR-0166), 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). Detected by content signature at open, on any file-reading source driver (sqlite, mydumper, csv/tsv/ndjson), before any data moves. | 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-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 the DuckDB cookbook). |
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 must evaluate the predicate CLIENT-SIDE per CDC change — there is no source-side stream 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). A case/accent-insensitive string equality on a UTF-8 collation is supported (v0.99.282+) — sluice evaluates it under the column's own collation, including trailing-space (PAD) semantics, so it matches the source exactly. What still refuses UP FRONT: a function call, a subquery, arithmetic, LIKE, an unknown column, a string ordering comparison (< <= > >=, collation-dependent), a string comparison on a collation sluice can't reproduce faithfully (a non-UTF-8 charset such as latin1, or a Postgres non-deterministic ICU collation), an equality on a FLOAT/DOUBLE column (the client compares exactly while the source coerces to a 64-bit double; float ordering is allowed — except a single-precision FLOAT ordering term on a PAD-SPACE-forced PlanetScale/Vitess table, which refuses because the cold-start copy carries single-precision FLOAT display-rounded), a timezone-aware temporal comparison, or unrecognized syntax — refused rather than evaluated in a way that 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 server-side filter is NO-PAD — is NOT refused as of v0.99.283: that table streams unfiltered server-side and is filtered client-side with the PAD-faithful comparator.) migrate --where is unaffected (it never evaluates client-side). | The common cases — equality / IN / IS NULL on a column, including case/accent-insensitive string matches (country IN ('US','CA')) — are supported directly. For a genuinely unreproducible one (a non-UTF-8 charset column, a non-deterministic PG collation, a float equality), either normalize on the source (e.g. a generated lower-cased column) and filter on that, or use sluice migrate --where (source-evaluated, full source-SQL) if you only need the initial subset. |
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-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 / verify with a --where TABLE=<predicate> filter refused at start because TABLE names no table in the source schema — a typo (--where user=… missing the s) or a case-fold mismatch. The migrate/verify readers match the filter by exact table name, so an unmatched key would silently disable the filter and copy/count the whole table — a scope-escape that verify, riding the same lookup, would then confirm as a false PASS. Refusing beats a silent whole-table copy (the continuous-sync leg already refuses the same class). Matching is case-insensitive; two keys that fold to the same table are refused. Added v0.99.279. | Correct the --where table name (matching is case-insensitive) or remove the entry, and pass the same --where to migrate and verify. |
