The migration debt account
The patterns this tool recommends are often two-step. Add a constraint NOT VALID, then
validate it. Expand, then contract. Build an index concurrently, then confirm it landed. Each first
step is the right move — it is what keeps a deploy from locking a table — and each leaves something
owed.
The failure mode is never dramatic. Nothing breaks, nothing is slow, no error is raised. The second step simply does not happen, and there is no symptom to notice. The ledger is the account of those open ends, so that "we will finish this next sprint" leaves a trace outside somebody's memory.
Where it lives
A repo file — sqlens-debt.json by default, deploy.debt.path to move it. It is committed, it is
reviewed like any other file, and it is never a database table: this package writes nothing to
your database, and an account that lived there would be invisible in a pull request anyway.
It is deterministically sorted and one entry per block, so a Git diff shows the debt somebody added and nothing else.
How a debt gets in
php artisan sqlens:lint --debt=record # what your migrations leave behind
php artisan sqlens:audit --debt=record # what the database already carried
Without the flag both commands compare and report — neither edits the file. That is the default because a tool that quietly writes to a committed file puts its own opinion into version control without anyone choosing it.
Two commands, because they see different halves and neither sees both. sqlens:lint reads your
migrations. sqlens:audit reads the live catalog, which is the only place a debt that predates this
package is visible at all — a constraint added NOT VALID before you installed SQLens has no
migration to read, and until --debt=record existed on the audit route it could never be recorded,
never aged and never acknowledged.
Neither command removes what it could not look at. A recording run prunes only inside the population it read, so an audit run leaves your migration debts alone and a lint run leaves the catalog-observed ones alone. "I did not look" and "it is not there any more" are different answers, and only one of them is safe to write to a file.
What first_seen means depends on which one recorded it
An entry carries origin, and it is not decoration:
origin | first_seen is | because |
|---|---|---|
migration | the day a run first saw the migration | as close to "when we did this" as a repository can honestly get |
catalog | the day this project first looked | nothing available to this package knows when a legacy object was created |
Both are honest, they are not the same claim, and until this field existed they looked alike. An age
measured from the day you started counting is still an age — it still separates a debt taken on last
week from one nobody has touched in a year — but a reader who took a catalog date for a creation
date would draw a wrong conclusion from a number this tool gave them.
One object is one entry, whichever command found it. An entry's identity is derived from the debt's kind and the object it is about, deliberately not from the migration: how you came to know about a constraint is not what the constraint is.
How a debt gets out
Finish the second step. The next recording run removes the entry, because the run no longer owes it.
Carrying one deliberately
Sometimes the answer is "yes, we know, and not this quarter". That is a legitimate decision, and the ledger has a place for it — you write it by hand, in the file, in the pull request where your colleagues can see it. There is no command for this on purpose: the decision is the valuable part, and a wizard would turn it into a keystroke.
Take the entry the recording run wrote and change three fields:
{
"id": "3a1c…",
"kind": "not_valid_constraint",
"rule_id": "PG.L4.CONSTRAINT_VALIDATION_PENDING",
"driver": "pgsql",
"object": "public.orders_amount_positive",
"migration": "database/migrations/2026_01_01_000001_add_constraint.php",
"origin": "migration",
"first_seen": "2026-01-01",
"state": "acknowledged",
"reason": "carried until the Q4 rewrite window — validating scans 400M rows",
"review_at": "2026-12-01"
}
state: acknowledged— the debt stops escalating with age. It does not disappear from the report: it is still listed, as something carried on purpose.reasonis required. An acknowledgment with no reason is refused outright, and the run says so: the state means somebody made a decision, and without the decision written down it is a suppression nobody argued for. The next reader would inherit a debt that quietly stopped escalating with no way to find out why.review_atis optional and worth setting. When the date passes, the run reportsLINT.DEBT.ACKNOWLEDGMENT_EXPIRED— the debt is not worse, the decision is due for renewal.nullmeans "carried indefinitely", which is an honest thing to write when it is true, and visible in the file either way.
Everything else in the entry is left alone. A recording run rewrites the file deterministically and
carries first_seen, state, reason and review_at through untouched — losing any of them would
silently revert your decision and restart the debt's age.
Upgrading a file written by an older build
The file declares a schema number. A ledger written at schema 1 is read in full by a current
build: identities have always been derived from an entry's own fields rather than trusted, so
they simply come out in the current form, and origin is recovered from the migration reference —
an empty one has always meant "found in the catalog rather than in a file". Nothing you committed is
lost, and there is no migration step to run. The file becomes schema 2 the next time a recording run
writes it.
The other direction is refused on purpose. A build that has never seen your schema number reports
a named undetermined rather than reading the file anyway — an older build would compute identities
its own way and report a plausible, wrong account, and "could not tell" is the only safe answer to
give instead.
When two branches both touch it
This file is one two people edit at the same time, so the format is shaped for that: one entry per block, a deterministic order that groups by driver and kind rather than by when something was written, and a trailing newline. Three cases go through Git untouched, and they are the three that actually happen:
| Both branches… | Result |
|---|---|
| record different debts | merges clean; all entries survive, and the file is byte-identical to what one run over the union would write |
| record the same debt | collapses to one entry — the id is derived from what the debt is, so both branches wrote the same bytes in the same place |
| one resolves a debt while the other records one | merges clean; the two touch different regions because the order is not chronological |
If you do get a conflict, resolve it by hand and keep both sides' entry blocks, then delete the markers. The next recording run puts the file back in canonical order.
Two things not to do:
- Do not regenerate the file to make a conflict go away. A recording run writes the debts this
run owes, so regenerating on one branch discards whatever the other branch recorded — and it drops
every
acknowledgedentry with the written reason that justified it. - Do not take one side wholesale when both changed the same entry. That is the shape a
"whichever, they look the same" resolution gets wrong: if one branch set
state: resolvedand the other still carries the entry as open, the two blocks are not interchangeable. Keep the one that records the newer lifecycle fact, along with itsreason.
Anything that survives a bad resolution is still caught rather than silently believed: a file that no
longer parses is reported as
LINT.DEBT.LEDGER_UNREADABLE, never as "no debts".
What the run tells you
| Finding | Means |
|---|---|
LINT.DEBT.UNRECORDED | This run owes a debt the ledger has never heard of. |
LINT.DEBT.STALE_ENTRY | The ledger records a debt this run does not owe — most likely somebody finished it. |
LINT.DEBT.ACKNOWLEDGED_GONE | A debt you chose to carry stopped being detected. Kept, not removed — the written reason is what would be lost. |
LINT.DEBT.ACKNOWLEDGMENT_EXPIRED | The review_at you set has passed. |
LINT.DEBT.LEDGER_UNREADABLE | The account exists and this build cannot act on it. Never reported as "no debts". |
LINT.DEBT.NOT_RECORDABLE | --debt=record was asked for with --file, which cannot see enough to record. |
Two things it deliberately does not do
It does not fail your build for having debts. Debts are a reported state. What escalates a
finding is its age, through the bands in deploy.debt.thresholds (notice at 30 days,
warning at 90, error at 180 — all configurable), and even then the escalation raises a severity
rather than inventing a gate.
It does not consult the account on a run that linted nothing. A run with no pending migrations, an unsupported engine or a stopped tool owes nothing — and reconciling against nothing would report every recorded debt as settled. A recording run in that state would empty your account on the strength of having looked at nothing.
Turning it off
'deploy' => ['debt' => ['enabled' => false]],
For a project that has not adopted the account and does not want a report about a file it never created. It is not a way to silence a debt somebody would rather not see — that is what an acknowledged entry with a written reason is for, and the difference is that one of them is visible.
Making an old debt fail a deploy
deploy.debt.fail_at is the age, in UTC calendar days, at which an outstanding debt breaks a
deploy. It ships as null, which means never.
// config/sqlens.php
'deploy' => ['debt' => ['fail_at' => 90]],
The default is not timidity. Debts are a reported state rather than a gate: the safe two-step patterns — expand then contract, add then validate — leave one on purpose, so a deploy command that refused to finish over a debt would punish exactly the projects doing the right thing.
Set it once the account has become something the team actually acts on, and set it to a number nobody crosses by accident. An acknowledged debt never breaks a run whatever its age: the decision to carry it has been made and written down, and overriding that from a config value would make the acknowledgment worthless.