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

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

{
"schema_version": 5,
"run": { "sqlens_version": "…", "mode": "…", "profile": "…", "level": 0, "evaluated_rules": ["…"], "…": "…" },
"summary": {
"overall_status": "fail",
"counts": { "status": {}, "level": [], "severity": {}, "downtime_class": {}, "category": {}, "undetermined_reason": {}, "suppressed_by_source": {} },
"worst_downtime_class": "blocking",
"level_gate": { "threshold": 2, "breaching": 1 },
"severity_gate": { "threshold": "critical", "breaching": 0, "undetermined": 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.
run.guardThe 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_rulesWhich 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.admitted_stabilityWhich 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_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. 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_classThe 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 — 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.suppressedHow many findings were hidden.
findingsEvery finding the gates saw. Each carries blocked_bylevel, 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.
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.

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: 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:

FieldWhat it does
result.levelthe 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

A finding published with the right level and no security-severity arrives with no weight at all — visible, gray, and below everything that carried a number.

SQLens severitylevelsecurity-severity
criticalerror9.0
higherror7.0
mediumwarning5.0
lowwarning3.0
infonote1.0

Only findings on the risk axis carry a weight. A safety, performance, idiom or convention finding takes its level from the level gateerror 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 logicalLocationpgsql.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 a briefing. 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_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": 2,
"entries": [
{"fingerprint":"…","ordinal":0,"rule_id":"PG.L2.INDEX_NOT_CONCURRENT","subject":"database/migrations/2026_01_01_000000_create_orders_table.php"}
]
}
FieldWhat it holds
schema_versionMandatory, and currently 2. A missing or unknown version is an error, never a guess — and a version-1 file is refused rather than migrated, because a v1 entry carries no category and reading one would mean guessing which axis its author accepted. 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[].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.