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
EXPLAINis 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.
| Missing | What goes undetermined | The reason you will see |
|---|---|---|
pg_read_all_settings | server settings | setting_unreadable, naming which setting was withheld |
pg_monitor (activity views) | lock blockers, replication lag | activity_unreadable / replication_views_unreadable |
SELECT on performance_schema | metadata locks, replication lag | performance_schema_unavailable, naming the instrument |
performance_schema = OFF | metadata locks | same — 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 instance | disk headroom | filesystem_headroom_unreadable — with 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:
| Value | What it means |
|---|---|
null | this producer has no gate to waive — a sqlens:lint run has no such flag |
false | a gate ran, fail-closed, and nothing was waived: the verdict is the checks' own |
true | the 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 disagreeDEPLOY.CONTEXT.READ_ONLY_TARGET— the target cannot accept writes at allDEPLOY.CONTEXT.SESSION_DEFENSE_NOT_APPLIED— the session bounds did not takeDEPLOY.CONTEXT.SETTING.LOCK_TIMEOUT_UNBOUNDEDDEPLOY.CONTEXT.SETTING.STATEMENT_TIMEOUT_UNBOUNDEDDEPLOY.CONTEXT.SETTING.IDLE_IN_TRANSACTION_UNBOUNDEDDEPLOY.CONTEXT.SETTING.MAX_WAL_SIZE_AT_DEFAULTDEPLOY.CONTEXT.SETTING.LOCK_WAIT_TIMEOUT_UNBOUNDEDDEPLOY.CONTEXT.SETTING.FOREIGN_KEY_CHECKS_OFFDEPLOY.CONTEXT.SETTING.SQL_MODE_NOT_STRICTDEPLOY.CONTEXT.SETTING.ONLINE_ALTER_LOG_AT_DEFAULTDEPLOY.CONTEXT.SETTING.PROVENANCE_UNAVAILABLE
Live preflight — what is happening right now
DEPLOY.PREFLIGHT.LOCK_BLOCKER— something holds a lock the migration needsDEPLOY.PREFLIGHT.CONCURRENT_INDEX_BLOCKER— an older transaction anywhere delays a concurrent buildDEPLOY.PREFLIGHT.METADATA_LOCK_BLOCKER— the MySQL equivalentDEPLOY.PREFLIGHT.REPLICATION_LAG— a replica is behind, or not streaming at allDEPLOY.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 isDEPLOY.PREFLIGHT.MISSING_PRIVILEGE— the migration role may not do what the migration asksDEPLOY.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 runANALYZEon is the commonest cause, and running it is the fix. Reported asundeterminedrather 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 finishDEPLOY.LEGACY.INVALID_INDEX_NAME_COLLISION— the same leftover, and a migration in THIS deploy re-creates its name, so the deploy stops at that statementDEPLOY.LEGACY.CONSTRAINT_NOT_VALIDATED—NOT VALIDwhere theVALIDATEnever followedDEPLOY.LEGACY.ORPHAN_TRANSITION_OBJECT— an object whose NAME says it was meant to be temporary:users_old,tmp_backfill_state,orders_20260721. Reported only bysqlens:postdeploy, and always asundetermined— a name is not evidence, and the sameorders_oldis what an abandoned rename leaves behind and what a team calls the archive it queries every quarter. Configure the shapes underdeploy.postdeploy.transition_patternsDEPLOY.LEGACY.OSC_ARTIFACT— MySQL only: what gh-ost, pt-online-schema-change or an InnoDBALGORITHM=COPYrebuild left behind. Reported bysqlens:postdeploy, and alsoundetermined— a run still in flight looks exactly like one that died. The leftoverpt_osc_*TRIGGER is the one to act on: it costs a write on every row of a live tableDEPLOY.LEGACY.CONSTRAINT_NOT_ENFORCED— MySQL only: a CHECK constraint withENFORCED = NO. It is in the catalog, inSHOW CREATE TABLEand 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.