The remediation payload
Every safety finding can carry a remediation payload: the safe sequence for the
problem it just reported, as structured material rather than as a sentence. It is
what --format=agent embeds, what the MCP server serves, and the attachment point a
third-party agent tool builds against.
This page is the field reference for that shape. The schema itself ships with the package and is fetchable:
resources/data/schemas/remediation-payload-v1.schema.json
SQLens hands you material. It never applies it
There is no field here that names a path, a command or an execution, and that is structural rather than an oversight: nothing in the payload could be run even by a consumer that wanted to. SQLens writes no migration file and issues no DDL.
The responsibility for what happens next belongs to whatever reads this. A tool that
turns a payload into an applied change is making a decision SQLens declined to make,
and it is making it in a place where the decision is usually a person's — the
manual_gate step kind exists to say so in the sequence itself.
The status is preview, and the payload says so itself
Every payload carries its own schema_version and stability, so a consumer reading
one never has to find this page to know what it may rely on. See
the public API
for what that buys in both directions.
An example, whole
A SET NOT NULL on a populated PostgreSQL table, which scans it under a lock — and the
four-step sequence that reaches the same end state without one:
{
"schema_version": 1,
"stability": "preview",
"rule_id": "PG.L2.SET_NOT_NULL_SCAN",
"strategy": "not_valid_then_validate",
"downtime_class": "blocking",
"debt_kind": "not_valid_constraint",
"preconditions": [
"sqlens::messages.remediation.constraint.precondition.no_null_rows",
"sqlens::messages.remediation.constraint.precondition.second_migration_is_planned"
],
"steps": [
{
"order": 1,
"kind": "migration_statement",
"note_key": "sqlens::messages.remediation.constraint.not_null_check",
"sql_template": "ALTER TABLE invoices ADD CONSTRAINT invoices_account_id_not_null CHECK (account_id IS NOT NULL) NOT VALID",
"within_transaction": true
},
{
"order": 2,
"kind": "separate_migration",
"note_key": "sqlens::messages.remediation.constraint.validate",
"sql_template": "ALTER TABLE invoices VALIDATE CONSTRAINT invoices_account_id_not_null",
"within_transaction": true
},
{
"order": 3,
"kind": "migration_statement",
"note_key": "sqlens::messages.remediation.constraint.set_not_null_trusts_it",
"sql_template": "ALTER TABLE invoices ALTER COLUMN account_id SET NOT NULL",
"within_transaction": true
},
{
"order": 4,
"kind": "migration_statement",
"note_key": "sqlens::messages.remediation.constraint.drop_the_now_redundant_check",
"sql_template": "ALTER TABLE invoices DROP CONSTRAINT invoices_account_id_not_null",
"within_transaction": true
}
],
"verification": "sqlens::messages.remediation.constraint.verification",
"references": [
"https://www.postgresql.org/docs/18/sql-altertable.html"
]
}
Field reference
| Field | Required | What it is |
|---|---|---|
schema_version | yes | Always 1 for this shape. Check it first — see below. |
stability | yes | Constant for the whole version-1 line. Not something a producer chooses. |
rule_id | no | Which rule this fixes, so a payload lifted out of its finding still says what it is about. |
strategy | yes | The named safe sequence. See the catalog below. |
downtime_class | no | online, blocking or rewrite — the reach of the sequence, not of the problem. |
debt_kind | no | The debt the sequence deliberately leaves open between its steps, in the ledger's vocabulary. |
preconditions | no | Translation keys for what must hold before step one. |
steps | yes | The sequence. See below. |
verification | no | Translation key for how to confirm it worked. |
references | no | Citations, as URIs. |
A field that is absent is absent — no producer emits null — so a reader checks for
presence rather than for a null value.
Steps
| Field | Required | What it is |
|---|---|---|
order | yes | Integer from 1. The order is the safety property, not a presentation detail. |
kind | yes | Where the step belongs. See the table below. |
note_key | yes | A translation key, never a sentence. |
sql_template | no | SQL with named placeholders. Never generated, never guessed. |
laravel_snippet | no | The same step as framework code, where one exists. |
within_transaction | yes | false is load-bearing — see below. |
A sequence applied out of order is not a slower fix. It is a different one, and usually
a broken one: validating a constraint before adding it does nothing, and dropping the
old column before the backfill runs loses the data. Sort by order and apply in that
order, or do not apply at all.
within_transaction: false is the field a consumer most easily drops, because true
looks like the safe default and Laravel wraps migrations in a transaction anyway. It is
not a default here. CREATE INDEX CONCURRENTLY and its relatives fail inside a
transaction, so a step marked false that a reader runs inside one produces an error
rather than an index.
Step kinds
| Kind | Where it belongs |
|---|---|
migration_statement | Inside the migration this finding is about. |
separate_migration | A migration of its own, usually a later one. |
queued_job | Work whose duration is unbounded, and which must not hold a migration open. |
session_setting | A session-level setting applied before the statements that follow it. |
manual_gate | A decision or a deploy step that belongs to a person, not to a script. |
The difference between the first two is the difference between a sequence that works and one that deadlocks on its own transaction.
Placeholders
sql_template carries named placeholders in double braces — {{table}}, {{column}},
{{index}} — filled deterministically from the finding's own context. Where a producer
knows the value it is already substituted, as in the example above; where the decision
is yours, the placeholder is what says so.
A placeholder that is still present is a question, not an omission. Filling one with a guess is how a fix template becomes an incident.
The strategies
Every value strategy can take, and what each names:
| Strategy | The sequence |
|---|---|
algorithm_lock_hint | Name the algorithm and lock level the engine would otherwise pick for you. |
batched_backfill | Move the data in bounded batches from a job rather than in one migration statement. |
charset_migration | Move the column to the new character set without a full-table conversion in place. |
concurrently | Build the index without taking the write lock the ordinary form takes. |
deploy_window_drop | Drop across two deploy windows so a rollback in between still finds what it needs. |
drift_correction | A schema difference the drift comparison found, and the shape of putting it right. |
enum_append_only | Extend the enumeration by appending, never by rewriting its existing members. |
expand_contract | Add the new shape, move readers and writers across, remove the old one — over three deploys. |
explicit_identifier | Give the object a name of your own, short enough for the engine — the fix for an identifier that goes over the limit. |
none | Looked at, and there is no safe standard sequence — a statement, not an absence. |
not_valid_then_validate | Add the constraint unvalidated, then validate it in a second migration. |
rewrite_avoidance | Reach the same end state without the operation that rewrites the whole table. |
timeout_preamble | Bound how long the statement may wait and how long it may run before it is given up on. |
transaction_split | Give each strong lock its own migration, so each is released before the next is taken. |
none is a conclusion, not an absence. A rule that returns it has looked at its
finding and decided there is no safe standard sequence — which is something a reader
can act on. A finding with no payload at all says only that nobody wrote one. The two
look equally empty and mean different things.
Consuming the payload
Read schema_version before anything else, and treat a version you do not know as a
payload you cannot use:
$payload = $finding['remediation'] ?? null;
if ($payload === null) {
return; // no template — which is not the same as "nothing to do"
}
if ($payload['schema_version'] > 1) {
return; // a newer shape than this code understands: ignore it, never guess at it
}
$steps = $payload['steps'];
usort($steps, fn (array $a, array $b): int => $a['order'] <=> $b['order']);
foreach ($steps as $step) {
$transaction = $step['within_transaction'] ? 'in a transaction' : 'OUTSIDE a transaction';
echo sprintf("%d. [%s, %s] %s\n", $step['order'], $step['kind'], $transaction, $step['sql_template'] ?? $step['note_key']);
}
Two rules make that loop safe, and both are about versions rather than fields:
- A HIGHER
schema_versionthan you understand: ignore the payload. Do not read the fields you recognize out of it and act on those — a later version may mean something different by the same key, and a half-understood fix sequence is worse than none. - Within a version, ignore keys you do not know. Fields are only ever added, never removed or renamed, so an unknown key is a newer producer and not a broken one.
stability is worth checking too, for a different reason: while it reads preview, the
shape can still move within a minor release. Code that asserts on it fails loudly when
that changes, which is better than code that silently keeps parsing.