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": 7,
"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",
"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.subject_countHow 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_dayThe 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.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

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.

#SourceWhat it is
1configthe project's sqlens.ignore list
2audit_ignorethe audit suite's own ignore list
3baselinea recorded entry in .sqlens-baseline.json
4annotationa #[SqlensIgnore] attribute on a migration class
5destructive_opt_inthe 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.