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 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-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 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 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-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-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-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 NaN/Infinity). 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 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-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 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 — 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). 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 (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://...). |
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 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-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 header-level and cheap — it walks the lineage, 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 decrypts chunks, 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-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-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. Three 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; or REPLICA IDENTITY is explicitly NOTHING. Adding such a table to a publication with pubupdate/pubdelete makes Postgres reject the source application's own writes to it — ERROR: cannot update table "x" because it does not have a replica identity and publishes updates, and the same for DELETE. 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, including on a keyless 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; always works (it publishes the whole old row, at the cost of WAL volume on every UPDATE/DELETE), 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. 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 --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. |
