Skip to main content

What sqlens:predeploy needs to be allowed to do — and what it never asks for

sqlens:predeploy runs immediately before migrate --force, against the database that is about to receive the migration. That is the most sensitive moment a tool can ask for access, so this page starts with what it will not ask for.

What SQLens never asks for

Four things, and none of them is a policy you have to configure — they are properties of the code:

  • No superuser. Every reading below works from an unprivileged role plus specific grants.
  • No write access. The preflight session is sealed read-only and PROVES the seal before reading anything: it attempts one write and requires the server to refuse it. A session that accepted the write ends the run rather than continuing.
  • No access to your data. Every query reads a catalog or state view. No user table is selected from, no EXPLAIN is run, and no row contents appear in any finding.
  • No locks of its own. Not one reading takes a lock, including the checks that are about locks.

The last two are not promises in prose. tests/Postgres/ and tests/MySql/ hold arms that count locks before and after each check and compare, and the read-only seal is exercised by a probe that must be refused.

The role

PostgreSQL

pg_monitor covers the activity and statistics views in one grant, and it is a read-only built-in role:

CREATE ROLE sqlens_preflight LOGIN PASSWORD '…';
GRANT pg_monitor TO sqlens_preflight;
GRANT CONNECT ON DATABASE your_database TO sqlens_preflight;
GRANT USAGE ON SCHEMA public TO sqlens_preflight;

pg_read_all_settings is included in pg_monitor. Without it, pg_settings silently loses rows rather than failing — the reading comes back short and no error is raised, which is why the server settings check asks whether the role holds it before drawing any conclusion.

MySQL

CREATE USER 'sqlens_preflight'@'%' IDENTIFIED BY '…';
GRANT PROCESS, REPLICATION CLIENT ON *.* TO 'sqlens_preflight'@'%';
GRANT SELECT ON performance_schema.* TO 'sqlens_preflight'@'%';

There is deliberately no grant on information_schema, and it is not an omission. MySQL refuses one — GRANT SELECT ON information_schema.* … fails with access denied, even as root — because the database needs none: every account can read it, and MySQL filters the rows it returns by what that account may see. A page that told you to run that statement would stop your setup halfway through with an error that looks like a permissions problem on your side.

Measured rather than assumed: performance_schema.threads is gated by SELECT on performance_schema, not by PROCESS. PROCESS is still needed — it is what makes other sessions visible at all — but granting it alone leaves the threads view empty, which reads exactly like a quiet server.

Point the preflight at it

// config/sqlens.php
'preflight' => [
'connection' => 'sqlens_preflight',
'budget_ms' => 5000,
],

Left null, the reading stops with a named reason. It does not fall through to your default connection — that is the one running your migrations, and a preflight reading through it works perfectly while quietly costing the least-privilege claim this package makes about itself.

Use a direct connection, not the pooler

Point the preflight connection past PgBouncer or ProxySQL where you can.

Under transaction pooling a SET is accepted, returns no error, and stops applying at the end of the transaction. The session defense therefore reads its timeouts back instead of trusting that they took, and reports DEPLOY.CONTEXT.SESSION_DEFENSE_NOT_APPLIED when what it asked for is not what is in force. That is the honest outcome, and it is also a blocked deploy — so the direct connection is worth arranging.

What goes undetermined without which privilege

A check that cannot look reports undetermined with a named reason. It never reports a pass.

MissingWhat goes undeterminedThe reason you will see
pg_read_all_settingsserver settingssetting_unreadable, naming which setting was withheld
pg_monitor (activity views)lock blockers, replication lagactivity_unreadable / replication_views_unreadable
SELECT on performance_schemametadata locks, replication lagperformance_schema_unavailable, naming the instrument
performance_schema = OFFmetadata lockssame — and the settings check keeps working, because the reader falls back to SHOW GLOBAL VARIABLES and loses only the values' provenance
statistics on a managed instancedisk headroomfilesystem_headroom_unreadablewith the estimated need attached, because that number is what you hold against your own monitoring

The last row is the shape to expect on any managed database: free filesystem space is not readable from inside the database, and no privilege changes that.

Fail-closed, and the one door out

A run that could not look has not established that the deploy is safe, so undetermined blocks. That means a database blip stops a deploy — deliberately, because the migrate step two lines later would have failed anyway, and failing at the gate is the cheaper of the two.

--allow-undetermined opens exactly one door: a run whose only blockers could not answer proceeds. A real finding still blocks, because that is a fact about the database rather than about this gate's reach.

sqlens.deploy.predeploy.allow_undetermined (default false) is the same decision taken once for a project instead of per run. It is the same single door — not a wider one — and it is the more dangerous of the two, because nobody re-reads a config file before a deploy.

It is an emergency exit, not a recommendation.

A waived run says so

Whichever way the hatch was opened, the run records it. Without that, a waived green and an earned one are the same exit code, the same green tick and the same report — and "predeploy passed" means two different things nobody can separate afterwards.

The run header carries undetermined_waiver, and it has three values rather than two:

ValueWhat it means
nullthis producer has no gate to waive — a sqlens:lint run has no such flag
falsea gate ran, fail-closed, and nothing was waived: the verdict is the checks' own
truethe run was blocked only by checks that could not answer, and the hatch let it through
{
"run": {
"profile": "ci",
"strict_undetermined": true,
"undetermined_waiver": true
}
}

A deploy script can gate on that key. The console header prints it whenever a gate ran, including when it is off — a line that appeared only on waivers would make its absence the claim, and an absent claim is the reading this package refuses everywhere else.

The undetermined findings themselves never disappear. The hatch removes their gate effect, not their presence: a report that also hid what it waived would be worse than no hatch at all.

The findings this gate produces

Every id below is stable and links to its own page under rules/.

Context truth — is this run measuring the right world?

  • DEPLOY.CONTEXT.VERSION_SKEW — the pinned version and the real server disagree
  • DEPLOY.CONTEXT.READ_ONLY_TARGET — the target cannot accept writes at all
  • DEPLOY.CONTEXT.SESSION_DEFENSE_NOT_APPLIED — the session bounds did not take
  • DEPLOY.CONTEXT.SETTING.LOCK_TIMEOUT_UNBOUNDED
  • DEPLOY.CONTEXT.SETTING.STATEMENT_TIMEOUT_UNBOUNDED
  • DEPLOY.CONTEXT.SETTING.IDLE_IN_TRANSACTION_UNBOUNDED
  • DEPLOY.CONTEXT.SETTING.MAX_WAL_SIZE_AT_DEFAULT
  • DEPLOY.CONTEXT.SETTING.LOCK_WAIT_TIMEOUT_UNBOUNDED
  • DEPLOY.CONTEXT.SETTING.FOREIGN_KEY_CHECKS_OFF
  • DEPLOY.CONTEXT.SETTING.SQL_MODE_NOT_STRICT
  • DEPLOY.CONTEXT.SETTING.ONLINE_ALTER_LOG_AT_DEFAULT
  • DEPLOY.CONTEXT.SETTING.PROVENANCE_UNAVAILABLE

Live preflight — what is happening right now

  • DEPLOY.PREFLIGHT.LOCK_BLOCKER — something holds a lock the migration needs
  • DEPLOY.PREFLIGHT.CONCURRENT_INDEX_BLOCKER — an older transaction anywhere delays a concurrent build
  • DEPLOY.PREFLIGHT.METADATA_LOCK_BLOCKER — the MySQL equivalent
  • DEPLOY.PREFLIGHT.REPLICATION_LAG — a replica is behind, or not streaming at all
  • DEPLOY.PREFLIGHT.INACTIVE_REPLICATION_SLOT — a slot with no consumer is holding WAL, so some of the headroom the rewrite needs is already spent. Reported before the headroom check because on a managed database that one cannot answer at all: free capacity is not exposed, and a retained slot is the commonest reason the answer would have been no — and the one the instance can NAME.
  • DEPLOY.PREFLIGHT.DISK_HEADROOM — the rewrite may need more space than there is
  • DEPLOY.PREFLIGHT.MISSING_PRIVILEGE — the migration role may not do what the migration asks
  • DEPLOY.PREFLIGHT.STATISTICS_UNREAD — a table a finding names came back with no statistics, so that finding could not be weighed against its size. The verdict itself is unchanged and correct; what is missing is only the raise a large object would have earned, which means a real problem can be reported quieter than it deserves. A table nobody has run ANALYZE on is the commonest cause, and running it is the fix. Reported as undetermined rather than swallowed, because "this table is small" and "nobody could measure this table" are different facts and only one of them is good news.
  • DEPLOY.CONTEXT.GRANT.OWNERSHIP_MISSING — it holds the grants and still cannot ALTER, because PostgreSQL has no grant for that

Legacy — wreckage from a previous attempt

  • DEPLOY.LEGACY.INVALID_INDEX — a concurrent build that did not finish
  • DEPLOY.LEGACY.INVALID_INDEX_NAME_COLLISION — the same leftover, and a migration in THIS deploy re-creates its name, so the deploy stops at that statement
  • DEPLOY.LEGACY.CONSTRAINT_NOT_VALIDATEDNOT VALID where the VALIDATE never followed
  • DEPLOY.LEGACY.ORPHAN_TRANSITION_OBJECT — an object whose NAME says it was meant to be temporary: users_old, tmp_backfill_state, orders_20260721. Reported only by sqlens:postdeploy, and always as undetermined — a name is not evidence, and the same orders_old is what an abandoned rename leaves behind and what a team calls the archive it queries every quarter. Configure the shapes under deploy.postdeploy.transition_patterns
  • DEPLOY.LEGACY.OSC_ARTIFACT — MySQL only: what gh-ost, pt-online-schema-change or an InnoDB ALGORITHM=COPY rebuild left behind. Reported by sqlens:postdeploy, and also undetermined — a run still in flight looks exactly like one that died. The leftover pt_osc_* TRIGGER is the one to act on: it costs a write on every row of a live table
  • DEPLOY.LEGACY.CONSTRAINT_NOT_ENFORCED — MySQL only: a CHECK constraint with ENFORCED = NO. It is in the catalog, in SHOW CREATE TABLE and in code review, and it admits every row it claims to refuse. This one FAILS rather than reporting undetermined — the catalog states it outright

What this gate is not

It does not replace sqlens:lint. The migrations are linted here too — through the same rule set, so what a preflight reports is always a subset of what lint reports — but a lint run in CI catches these things days earlier and without a database. This gate exists for the half CI cannot know: the state of the instance in the minute before the deploy.