Skip to main content

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:doctor ships, and it always returns 0.

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".

CodeNameMeaning
0cleanNothing breached a gate, and every check that ran could reach a verdict.
1findings above gateAt least one finding crossed the level gate or the security severity gate.
2misconfigurationThe configuration, the baseline, or a suppression could not be trusted. Nothing was checked on that basis.
3undetermined in strict modeA 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:

  1. misconfiguration — beats everything, including a run with no findings at all. A tool that cannot trust its own setup never reports green.
  2. undetermined under strict mode — a run holding a check that could not run can never be 0 while strict mode is on.
  3. a gate breach
  4. 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": []
}
FieldWhat it holds
schema_versionThe 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.
runThe reproducibility header: versions, mode, profile, strict flags, and the server version per connection with whether it was detected or pinned.
summary.overall_statuspass, fail or undetermined — never a two-valued verdict.
summary.countsPer 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_gateEach gate with its own threshold and its own breach count. They are never merged.
summary.suppressedHow many findings were hidden.
findingsEvery finding the gates saw.
suppressedEvery 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_version in 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 without confidence leaves 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.

ValueWhat it means
onlineNo blocking lock is held beyond the statement itself. Safe during traffic.
blockingTakes a blocking lock (on PostgreSQL, typically ACCESS EXCLUSIVE) on a table that is in use. Everything touching that table waits.
rewriteRewrites 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:

TierPromise
stableThe rule id, its message prefix and its meaning are covered by the version policy above.
previewThe rule works and is opt-in. Its id and message may still change in a minor release.
experimentalAvailable 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"}
]
}
FieldWhat it holds
schema_versionMandatory. A missing or unknown version is an error, never a guess.
entries[].fingerprintThe 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[].ordinalTells apart several genuinely identical findings in one subject. Assigned deterministically.
entries[].rule_id, entries[].subjectCarried 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.