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. SQLens is in development, so this page describes the surface the 1.0 release will guarantee, not a frozen one. The gating commands that return the exit codes below (lint, audit, security) are still being built; today the read-only
sqlens:doctorships, and it always returns0.
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 gating commands that make this concrete (lint, audit, security) are still being built; the wiring above is the contract they will honor, documented here so a pipeline can be written against a stable exit-code surface from the first release.
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.
{
"schema_version": 2,
"run": { "sqlens_version": "…", "mode": "…", "profile": "…", "level": 0, "…": "…" },
"summary": {
"overall_status": "fail",
"counts": { "status": {}, "level": [], "severity": {}, "downtime_class": {}, "category": {}, "undetermined_reason": {}, "suppressed_by_source": {} },
"level_gate": { "threshold": 2, "breaching": 1 },
"severity_gate": { "threshold": "critical", "breaching": 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. |
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. |
summary.suppressed | How many findings were hidden. |
findings | Every finding the gates saw. |
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. |
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 + .rewrite) == 0'
That exits non-zero when anything in the run blocks or rewrites. Every class is present even at zero, so the keys are always there to read.
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".
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.
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": 1,
"entries": [
{"fingerprint":"…","ordinal":0,"rule_id":"PG.L2.CONCURRENTLY","subject":"database/migrations/2026_01_01_000000_create_orders_table.php"}
]
}
| Field | What it holds |
|---|---|
schema_version | Mandatory. A missing or unknown version is an error, never a guess. |
entries[].fingerprint | The stable identity of a finding: its rule id, its normalized location, and 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. |
entries[].ordinal | Tells apart several genuinely identical findings in one subject. Assigned deterministically. |
entries[].rule_id, entries[].subject | Carried for legibility. They are not part of the identity. |
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.
- 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
Three layers can hide a finding, resolved in a fixed order: the baseline, then
the project's sqlens.ignore list, then a #[SqlensIgnore] attribute on a
migration class. The first one that covers a finding wins, and the report always
names which layer that was and why.
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.