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-v3.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": 3,
"stability": "preview",
"rule_id": "PG.L2.SET_NOT_NULL_SCAN",
"subject": "statement",
"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.add_not_null_not_valid",
"sql_template": "ALTER TABLE invoices ADD CONSTRAINT invoices_account_id_not_null NOT NULL account_id 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": "manual_gate",
"note_key": "sqlens::messages.remediation.constraint.debt_until_validated_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 3 for this shape. Check it first — see below. |
stability | yes | Constant for the whole version-3 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. |
subject | yes | statement or schema_object — what the template is ABOUT, and which of the fields below apply. See the note under the table. |
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. |
subject decides which of the fields above mean anything
statement is the shape every payload had before this field existed, and it is the default: the
template rewrites a statement you wrote. Placeholders are filled from the canonicalized statement,
downtime_class describes what applying this deploy does, and a migration_statement step
means "in this migration".
schema_object is a template for a migration that does not exist yet, about an object the run found
in your database — an unvalidated constraint, a table without a primary key. Two things change:
downtime_classmust be absent. There is no deploy here whose effect it could describe. What the fix costs arrives when somebody writes the migration and depends on what they write, so a value would be a sentence about a deploy that does not exist — and exactly as legible as a true one. A payload that carries one is refused rather than rendered.- No step is a
migration_statement. There is no migration at hand to put it in.separate_migrationis the right kind, and it is the honest one: the fix for a state IS a new migration.
The field is emitted on every payload, including the common case. A discriminator that appeared only on the unusual shape would leave you inferring the other one from its absence, which is the guessing it exists to end.
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. |
reindex_before_refresh | Rebuild the indexes FIRST, and only then tell the server the recorded version is current. |
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'] > 3) {
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, the field set does not move. The document is strict
(
additionalProperties: false), so a key it does not describe is not a newer producer — it is a payload from a version you have not read. That is the whole reason the number exists, and it is enforced rather than promised: every published version's field set is frozen by digest in the package's own suite, so a change to one cannot ship without a new number and a new document.
What changed between version 2 and version 3
Version 3 adds one value to strategy: reindex_before_refresh. Nothing else moved.
It needed a version of its own because strategy is a closed enum here — a consumer
validating a version-3 payload against the version-2 document rejects it on a value that document
never described. That is the same reason subject needed one, arriving from the other direction:
the first was a key the older document refuses, this is a value.
Version 2 stays in the package, unchanged, at
resources/data/schemas/remediation-payload-v2.schema.json, beside version 1 — so a payload
written under either can still be validated against the contract it was written under.
What changed between version 1 and version 2
Version 2 adds one field, subject, and one conditional: a schema_object payload
must carry no downtime_class. Everything else is unchanged.
A version-1 payload is a statement. That is the value subject would have had,
so an archived payload can be read as one without guessing.
The bump is here rather than folded into version 1 because the document is strict in
both directions. A consumer holding the version-1 schema rejects a payload carrying
subject, and the version-2 schema requires it, so a version-1 payload fails against
that one. Both payloads would have claimed to be version 1 — the one state the field
exists to prevent. Version 1 stays in the package, unchanged, at
resources/data/schemas/remediation-payload-v1.schema.json, so a payload written
under it can still be validated against the contract it was written under.
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.