The public contracts
Three things about SQLens end up wired into other people's systems: the exit code a pipeline branches on, the JSON envelope a script parses, and the baseline file a repository commits. This page is what those three promise.
From 1.0 on, everything on this page is a commitment: a documented field or code changes only in a major release, and anything still moving is marked as such here, not left for you to discover.
Pre-1.0. The public API is documented and is what a 1.0 will commit to, but it is not frozen, so this page describes the surface the 1.0 release will guarantee. Everything below is implemented and returned by the commands that ship today; what a pre-1.0 marking means here is that a field or a code may still change in a minor release, not that it is unbuilt.
Exit codes
sqlens commands return one of four codes. They are the whole vocabulary — there
is no fifth value, and none of them means "probably fine".
| Code | Name | Meaning |
|---|---|---|
0 | clean | Nothing breached a gate, and every check that ran could reach a verdict. |
1 | findings above gate | At least one finding crossed the level gate or the security severity gate. |
2 | misconfiguration | The configuration, the baseline, or a suppression could not be trusted. Nothing was checked on that basis. |
3 | undetermined in strict mode | A check could not run, and strict_undetermined says that is a failure. |
0, 1 and 2 carry the meanings Symfony's console reserves for them, so 3 is
the first value SQLens is free to define. Every code stays inside 0–255, because
a process exit above that is clamped and would silently arrive as a different code
than the one promised.
Precedence
Two conditions can hold at once. The order is fixed, so the same run always produces the same code:
- misconfiguration — beats everything, including a run with no findings at all. A tool that cannot trust its own setup never reports green.
- undetermined under strict mode — a run holding a check that could not run
can never be
0while strict mode is on. - a gate breach
- otherwise clean
Which gate broke is not encoded in the exit code. Both axes report 1; the
reporter says which one, because a strictness choice and a security risk are
different facts and a single number cannot carry both.
Wiring it into a deploy
The exit code is the whole interface — no output parsing required. A gating command
returns one of the four codes above, and a pipeline branches on it: 0 proceeds to
migrate, a 2 is treated like a red build (the gate could not be trusted, so
nothing was actually checked — never read it as "no findings"), and any other
non-zero stops the deploy. One shell caveat worth stating, because it is the usual
way a gate silently passes: capture the code in the same step that runs the command
(command || status=$?), never with a bare if command; then … fi — a failed
condition with no else leaves $? at 0, so a later status=$? reads success on
a failed run.
The commands that make this concrete are sqlens:lint, sqlens:audit and
sqlens:security for the three checking suites, and sqlens:predeploy /
sqlens:postdeploy around the migration itself. All of them return the four codes
above. The integration guide has the
deploy wiring end to end.
The JSON envelope
--format=json writes one document to stdout. Diagnostics go to stderr, so
redirecting stdout to a file (--format=json > findings.json) always leaves a
parseable document behind, whatever the command wrote to the terminal.
The document is written byte for byte. A finding quotes migration SQL, catalog names and
file paths, and that text reaches the document as it is: a CHECK constraint with
'[\<\>]' in it or a table named <info>legacy</info> stays exactly that. The same holds
for SARIF and for --format=json on sqlens:format and sqlens:doctor. The formats a person
or a log reads (console, github, agent) print that text as it is too, and show a
control character in it as a visible escape, \x1B for ESC. A name can therefore not move
the cursor or erase a line in a terminal or a CI log.
{
"schema_version": 10,
"run": { "sqlens_version": "…", "mode": "…", "profile": "…", "level": 0, "evaluated_rules": ["…"], "subject_count": 32, "run_day": "2026-09-21", "…": "…" },
"summary": {
"overall_status": "fail",
"counts": { "status": {}, "level": [], "severity": {}, "downtime_class": {}, "category": {}, "undetermined_reason": {}, "suppressed_by_source": {} },
"worst_downtime_class": "blocking",
"downtime_undetermined": 0,
"level_gate": { "threshold": 2, "breaching": 1 },
"severity_gate": { "threshold": "critical", "breaching": 0, "undetermined": 0 },
"suppressed": 0
},
"findings": [],
"suppressed": []
}
| Field | What it holds |
|---|---|
schema_version | The envelope format. Bumped when a field is renamed or removed — and when one is added, so that a missing field tells you the run had nothing to say, not that the producer is older than the field. |
run | The reproducibility header: versions, mode, profile, strict flags, and the server version per connection with whether it was detected or pinned. The version string carries a mark when the run reasons from a version below the supported floor, and when the server is newer than the release the rules were measured against (— newer than 8.4, …); compare versions on its leading number, not on the whole string. |
run.guard | The production guard's verdict for a database-creating run — the environment it saw, whether --force was in effect, and allowed or the named check that held it: disallowed_environment, production_connection, or not_confirmed. null for every run that asked no guard, which is almost all of them; read that null as no database-creating mode was involved, never as one ran and was waved through. Without it a blocked run reports only shadow_guard_blocked on every finding, and the three causes need three different fixes. |
run.evaluated_rules | Which rules were actually handed a subject to judge. Neither active_rules nor hidden_rules can tell you this — both are fixed before anything is read, so they are the same number over a full catalog and an empty one. A rule missing from this list judged nothing, so its silence in findings means never asked, not asked and clean. null when the producing suite does not report the set; [] means a run that evaluated no rule at all, which is a different and much louder answer. |
run.subject_count | How many subjects the run judged — the denominator behind every count in summary. A lint run counts the migrations it read. null means the producer states no count, which an audit reading a catalog rather than a file list does, and it is a different answer from 0. Read it beside fail: a gate on fail == 0 alone cannot tell a clean tree from one where the path matched a fraction of the migrations. Measured in a consumer: 0 fail over 32 migrations, on a tree that held 298 more in a nested directory. Added in schema version 6. |
run.run_day | The calendar day this run judged on, as Y-m-d in UTC. It is the ONE reading the rules got, not a second one taken while the report was written: a support window closes on a date and a debt acknowledgment expires on one, so a run crossing midnight used to be reproducible only by luck — the report named no day at all, and re-examining a verdict meant guessing which side of midnight it came from. null means the producer handed no clock down. Added in schema version 7. |
run.sealed_by | What the read-only guarantee of a run that reads the catalog rested on (sqlens:audit, sqlens:predeploy and sqlens:postdeploy), as its session proved it before the first read: session when the session's read-only flag holds, privilege when the account's grants were read and name nothing but reading privileges. Both mean the run could not write; privilege rests on grants you can check in the database without trusting this package. PostgreSQL refuses any DDL inside a read-only transaction before it looks at a grant, so a run there always says session. MySQL checks the grant first: an account without the right to create a temporary table has its write probe refused by that grant, so its grants and the session flag are read instead, and an account that may write anywhere says session. A session whose flag is off stops the reading with an error. null on every run that read no catalog, and on one that stopped before its first read. It is never null because a session was unsealed: a session that accepts the probe stops the reading with an error. Added in schema version 8. |
run.session_timeouts | The timeouts the reading session reported in force, in milliseconds, read back from the server rather than taken from what it asked for. 0 means the server enforces no bound; a null value means it did not answer with that timeout. The audit and the predeploy suite fill it; null as a whole on a run that opened no session. |
run.admitted_stability | Which maturity tiers this run admitted — what was allowed to run, never what reported. ["stable"] on an unconfigured project, because empty configuration means stable-only on this axis (the opposite of categories, where empty means all). It answers the question the per-finding stability marker cannot: that marker sits on a finding, so it only ever describes a rule that FOUND something. A run that admitted preview and found nothing looked exactly like a run that never admitted it — same header, different coverage. |
summary.overall_status | pass, fail or undetermined — never a two-valued verdict. |
summary.counts | Per status, level, severity, downtime class, category, undetermined reason, and suppression source. Every key of each axis is present even at zero, so the shape never varies. |
summary.level_gate / summary.severity_gate | Each gate with its own threshold and its own breach count. They are never merged. The risk gate carries a third number — security and privacy checks that did not conclude — beside the breach count rather than folded into it: a breach is something the run found, an undetermined is something it could not look at, and on a managed database the second is often the larger. |
summary.worst_downtime_class | The most disruptive class any finding in the run carries: online, blocking, rewrite — or null. This is the field a deploy script reads to decide whether a release needs a maintenance window. It could be derived from counts.downtime_class, and it is provided anyway, because deriving it means knowing the ORDER of the three classes: that order is this package's judgment, it is not alphabetical, and a consumer who re-derives it will eventually rank rewrite below blocking without noticing. null means no finding stated a class, or a finding's class could not be determined and no other finding names rewrite: that one could be the worst. Never read it as online. Most findings carry none at all, because the class describes a schema OPERATION and a security or convention finding is not one. The statistics escalation raises severities and never moves this: how long a rewrite takes depends on the row count, whether it IS a rewrite does not. |
summary.downtime_undetermined | How many findings carry a statement whose downtime class could not be determined, each with its downtime_undetermined reason. It sits beside counts.downtime_class rather than in it, because the classes are a closed set and an unknown is not a fourth one. A gate that decides from the histogram adds it to what blocks. Added in schema version 9. |
summary.suppressed | How many findings were hidden. |
findings | Every finding the gates saw. Each carries blocked_by — level, severity, or absent when it did not block — and exactly one of the two dials that apply to it: a security or privacy finding has a severity and no level, because the level gate never measures it; everything else has a level and no severity. A finding whose rule declares limits carries them as limitations (see below). |
suppressed | Every hidden finding, whole, plus a suppression object naming the source and the reason. Suppression removes a finding from the gate, never from the record. |
SARIF: validated, not eyeballed
--format=sarif writes a SARIF 2.1.0 log, and the output of several runs is validated against the
real schema on every local gate — a populated run, an empty one, and a catalog-only one. A negative
arm feeds the validator documents the spec forbids, so a validator that silently accepted everything
cannot pass as a clean result.
That arm earned its place on its first run: it caught this package emitting invocation where SARIF
names the key invocations, an array. A run object sets additionalProperties: false, so the
singular form was not a stylistic slip but a document every strict validator rejects.
SARIF: the run header travels with the log
invocations[0].properties carries the same header as the JSON report's run, instance included:
the host, port and database of the audited server go wherever the log is uploaded, into a CI artifact
or the code-scanning tab. A log from a run against production names production.
SARIF: what GitHub actually weighs
--format=sarif writes a SARIF 2.1.0 log. Two properties in it decide how a finding looks in the
code-scanning tab, and only one of them is the obvious one:
| Field | What it does |
|---|---|
result.level | the icon — error, warning, note, or none for a result that is not a problem |
rule.properties["security-severity"] | the weight GitHub sorts and filters by. A numeric string |
rule.properties.tags | the rule's category, and security for a rule on the risk axis: GitHub reads the weight only on a rule with that tag |
A finding published with the right level and no security-severity, or with a weight on a rule
that is not tagged security, arrives with no weight at all — visible, gray, and below
everything that carried a number.
A result that is not a problem says so twice: level is none, and kind is pass or
notApplicable, because SARIF reads a result without a kind as a failure. A fail and an
undetermined keep the default, so both stay alerts.
Every rule descriptor carries a fullDescription, what the rule checks, and a help in text and
Markdown that adds what the rule does not check and links its page. GitHub shows both on the
alert. A rule that declares no limitations has none listed.
One alert per finding, from run to run. Each result carries the finding's baseline fingerprint as
partialFingerprints["sqlensFindingFingerprint/v1"], so GitHub keeps one alert per finding across
runs. The same finding twice in one subject, such as one statement written twice in a migration,
shares that fingerprint. From its second occurrence on, the result also carries
sqlensOccurrence/v1 with the number, counted as the baseline's ordinal counts, so GitHub shows
each occurrence.
| SQLens severity | level | security-severity |
|---|---|---|
| critical | error | 9.0 |
| high | error | 7.0 |
| medium | warning | 5.0 |
| low | warning | 3.0 |
| info | note | 1.0 |
Only findings on the risk axis carry a weight. A safety, performance, idiom or convention finding
takes its level from the level gate — error when your configured level would block it,
warning when it is real and below that threshold — and carries no security-severity at all. The
two axes stay distinguishable in the output, exactly as they do everywhere else.
A finding about your live database still gets a location. A table, a role or a server variable is
nowhere in your repository, and SARIF needs a physical location for GitHub to attach an alert to. The
report carries the real subject as a logicalLocation — pgsql.public.orders, without the connection
name, which is deployment detail — and anchors the alert to one repository file with no invented
line. The anchor defaults to config/sqlens.php and is configurable:
'reporting' => [
'sarif' => ['anchor_file' => '.github/sqlens-alerts.md'],
],
The message names the object first, because GitHub shows the anchor file in the alert header — so nobody reads the alert as a finding about that file.
An undetermined is a note, and says so. It carries
result.properties["sqlens-state"] = "undetermined" beside its named reason. On a managed database
most security checks cannot conclude, so weighting them heavily would make every upload a wall of
alerts — and attention that is always spent is attention nobody has left. What turns an unanswerable
check into a failing run is strict_undetermined, which is your decision, not the report's.
SARIF carries no fixes, deliberately
SARIF defines a fixes array, and this package's findings frequently carry a full remediation
payload that would fit it. The key is left empty on purpose, and the reason is what your tooling
does with it rather than anything about the format: GitHub renders a SARIF fixes entry as an
applicable change — a button a reviewer presses without reading the diff.
Applied to a migration, that is the worst possible moment for a one-click edit. The remediation
schema is still preview: no consumer has yet reported back on a payload, so the format has not
earned the trust a button implies. A schema proves itself as advice first, and only then as an
action.
The advice itself is not withheld. --format=json carries the remediation in full — every step,
its order, its transaction requirement, and the debt it leaves open — and --format=agent renders it
as instructions an agent can follow. What SARIF and the GitHub annotation refuse is the applicable-change surface, not the
material. The annotation has a second reason of its own: it is a single length-limited line, so a
multi-step sequence would arrive truncated, and half a remediation reads exactly like a whole one.
The decision is revisited when the remediation schema is promoted from preview to stable — that
promotion is the trigger, not a release date and not a judgment that it looks ready. Until then a
test holds the output to it, so this paragraph cannot quietly stop being true.
The schema-version policy
- Adding a key bumps
schema_versionin a minor release. It is not a breaking change — parse defensively and ignore keys you do not know — but it is announced, because otherwise a report withoutconfidenceleaves you unable to tell whether the run had nothing to say about confidence or whether the producer is older than the field. Those call for different handling. - Renaming or removing a key, or changing the meaning of an existing one, bumps it and is a major release.
- The envelope carries no timestamp and no absolute path. Both would make two runs of an unchanged project differ, and a diff that always shows changes is a diff nobody reads.
Downtime class
A safety finding carries a downtime_class — the field a deploy script reads to decide
whether a migration can go out during traffic. It is a closed set, and the values are
public API: they are never renamed, and a new one would be a breaking change.
| Value | What it means |
|---|---|
online | No blocking lock is held beyond the statement itself. Safe during traffic. |
blocking | Takes a blocking lock (on PostgreSQL, typically ACCESS EXCLUSIVE) on a table that is in use. Everything touching that table waits. |
rewrite | Rewrites the table physically. The duration grows with the row count, so a table that is small today is not a guide to production. |
They are ordered: rewrite outranks blocking outranks online.
The report head aggregates them, so a release gate can decide without walking every finding:
php artisan sqlens:lint --format=json \
| jq -e '.summary | .counts.downtime_class.blocking + .counts.downtime_class.rewrite + .downtime_undetermined == 0'
That exits non-zero when anything in the run blocks or rewrites, or when a class could not be determined. Every class is present even at zero, so the keys are always there to read.
A class that could not be determined is said, with its reason. A MySQL statement's
class comes from the online-DDL matrix, and some entries depend on the live table: a
DROP COLUMN is instant unless the column carries a functional index or one of three
other conditions holds. A run that did not read the table cannot settle that. The
finding then carries no downtime_class but a downtime_undetermined object instead,
{"reason": "online_ddl_condition_undecidable", "detail": "column_has_functional_index"},
and summary.downtime_undetermined counts it. It is not a fourth class: the classes stay
the closed, ranked set above.
Required for safety findings, optional elsewhere. A rule in another category may
declare one — a lifecycle rule that knows its change is online, say — and the value
travels unchanged. A rule that declares none leaves the field out of the report rather
than emitting null, so absence means "this rule makes no claim about downtime", not
"the downtime is unknown". Unknown is downtime_undetermined.
The value comes from the rule's own declaration and is attached where findings are collected, so a rule states it once rather than repeating it on every finding it emits.
Confidence
Every finding carries a confidence of deterministic or heuristic, and the console
adds one fixed sentence under a heuristic one.
A deterministic rule reads the captured SQL and knows. A heuristic rule reasons from a
pattern that is usually right: an UPDATE with a wide predicate probably touches a lot
of rows, but whether the surrounding code processes them in batches is not in the SQL.
Both reach a verdict; only one of them can be proven from what was captured.
heuristic is not undetermined. An undetermined finding means the check could not
run — no server version, no statistics reader, an unparseable migration — and the honest
answer is "unknown". A heuristic finding is the other situation: the check ran and
reached a conclusion that carries a margin. Gate on them differently.
The sentence comes from one place rather than from each rule, so two heuristic findings never read as if they carried different degrees of doubt.
Stability tiers
Every finding carries a stability tier, and the console marks anything that is not stable:
| Tier | Promise |
|---|---|
stable | The rule id, its message prefix and its meaning are covered by the version policy above. |
preview | The rule works and is opt-in. Its id and message may still change in a minor release. |
experimental | Available to try. It may change or disappear in any release. |
A rule is never deleted. When it is superseded it becomes deprecated, and a suppression that names it keeps working while the notice says what replaced it.
What a rule does not check
A finding whose rule declares limits carries them as limitations: a list of sentences, each
naming a case the rule deliberately does not answer. They come from the shipped rule catalog,
so explain_rule gives the same list. The key is absent where the rule declares none.
The console prints the same sentences once per rule beneath the findings, and --format=agent
puts them under each finding in its list of what to fix. SARIF does not carry them: a
descriptor exists only for a rule that produced a result, and GitHub code scanning shows no
custom descriptor property.
Read a clean result with them in mind. A rule that found nothing found nothing within its
limits, and explain_rule names them for any rule, including one that reported nothing.
The baseline file
The baseline records the findings a project has accepted. It is a file in the repository — SQLens writes no database state at all, ever.
{
"schema_version": 3,
"entries": [
{"fingerprint":"…","ordinal":0,"rule_id":"PG.L2.INDEX_NOT_CONCURRENT","subject":"database/migrations/2026_01_01_000000_create_orders_table.php","kind":"migration","category":"safety","severity":null}
]
}
| Field | What it holds |
|---|---|
schema_version | Mandatory, and currently 3. A missing or unknown version is an error, never a guess — and an older file is refused rather than migrated. A v1 entry carries no category, and reading one would mean guessing which axis its author accepted. A v2 fingerprint carries no statement, so the findings of one rule in one migration were told apart by their order alone, and matching one would hand its acceptance to whichever finding now stands in its place. The refusal names the command that regenerates the file. Regenerate with php artisan sqlens:baseline; the entries you accepted are re-derived from the same findings. |
entries[].fingerprint | The stable identity of a finding: its rule id, its normalized location, and, for a finding about a migration statement, a hash of the canonicalized statement. Deliberately free of line numbers and statement indexes, so editing the lines above a finding does not lose its entry, and inserting a statement above it does not hand its entry to another finding. A finding about a catalog object is identified by the object's name instead. |
entries[].ordinal | Tells apart several genuinely identical findings in one subject. Assigned deterministically. |
entries[].rule_id, entries[].category, entries[].severity | What a reviewer reads to judge the acceptance: which rule, which gate, what risk. Not part of the identity, and binding all the same. An entry whose rule, category or severity differs from the finding it matches suppresses nothing, see below. |
entries[].subject | Carried for legibility. Not part of the identity. |
entries[].kind | What the accepted finding was about: migration, catalog or callsite. The lint suite and the audit read the same file, and each one judges only the entries of the kinds it reports: the audit those about catalog objects, the lint suite the others. Required, because a guessed kind would hand an entry to the suite that cannot match it, and that suite would report it as stale on every run. An entry whose kind no suite of its rule reports is refused, like an unknown rule id. An entry you add by hand for an audit finding carries catalog, and sqlens:baseline keeps such entries when it rewrites the file. |
Why the layout looks like that
Entries are sorted by subject, then rule id, then ordinal, then fingerprint, and each one occupies exactly one line. Accepting or dropping a finding is then a one-line diff rather than a six-line reflow, and two people accepting different findings in the same week do not collide. There is no timestamp and no aggregate count, because either would change on every write.
What a baseline will not do
- It never hides an
undetermined. A check that could not run is not a finding anyone accepted, and hiding it would hide the fact that it never ran. - An entry that matches nothing in a run is reported, so a baseline nobody prunes cannot quietly grow shut. Only the suite the entry belongs to reports it: an entry about a migration matches nothing in an audit, and calling it stale there would send you to delete a line the next lint needs.
- It never accepts a finding its entry misdescribes. An entry whose rule, category or
severity differs from the finding it matches suppresses nothing, and the finding carries a
sentence naming what the entry records.
sqlens:baselinewrites all three from the finding, so a mismatch means the entry was edited by hand or outlived a release that re-rated its rule.sqlens:baseline --updaterewrites such an entry from the finding and names each one it rewrote, so the diff shows what is really being accepted. - A file this build cannot fully understand stops the run with code
2, rather than being read as far as it happens to parse.
Suppression
Five layers can hide a finding, and they are resolved in a fixed order. The first one that covers a finding wins, and the report always names which layer that was and why.
| # | Source | What it is |
|---|---|---|
| 1 | config | the project's sqlens.ignore list |
| 2 | audit_ignore | the audit suite's own ignore list |
| 3 | baseline | a recorded entry in .sqlens-baseline.json |
| 4 | annotation | a #[SqlensIgnore] attribute on a migration class |
| 5 | destructive_opt_in | the project's standing consent to a destructive operation |
The baseline is third, not first, and the position is the design rather than an accident. A baseline records findings somebody intends to fix, and that number is meant to fall; an ignore list records that a rule never applies here, and that number is meant to stay. If the baseline won a tie, a standing decision would land on a burn-down list it can never leave, and the total would stop falling for a reason nobody can find in the list. A baseline entry shadowed this way is still counted as matched, so it is not then reported as stale — telling you to delete a line that legitimately covers a finding is worse advice than saying nothing.
The order is fixed in code, not derived from how the sources were configured, so the same project always reports the same reason for the same hidden finding.
Two further layers sit below these and hide nothing anybody decided: they drop a finding another rule of this package already reports, so one fact is not stated twice. They come last on purpose — when a human decision and a de-duplication both apply, the decision is the better reason to show.