Skip to main content

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

FieldRequiredWhat it is
schema_versionyesAlways 3 for this shape. Check it first — see below.
stabilityyesConstant for the whole version-3 line. Not something a producer chooses.
rule_idnoWhich rule this fixes, so a payload lifted out of its finding still says what it is about.
subjectyesstatement or schema_object — what the template is ABOUT, and which of the fields below apply. See the note under the table.
strategyyesThe named safe sequence. See the catalog below.
downtime_classnoonline, blocking or rewrite — the reach of the sequence, not of the problem.
debt_kindnoThe debt the sequence deliberately leaves open between its steps, in the ledger's vocabulary.
preconditionsnoTranslation keys for what must hold before step one.
stepsyesThe sequence. See below.
verificationnoTranslation key for how to confirm it worked.
referencesnoCitations, 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_class must 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_migration is 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

FieldRequiredWhat it is
orderyesInteger from 1. The order is the safety property, not a presentation detail.
kindyesWhere the step belongs. See the table below.
note_keyyesA translation key, never a sentence.
sql_templatenoSQL with named placeholders. Never generated, never guessed.
laravel_snippetnoThe same step as framework code, where one exists.
within_transactionyesfalse 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

KindWhere it belongs
migration_statementInside the migration this finding is about.
separate_migrationA migration of its own, usually a later one.
queued_jobWork whose duration is unbounded, and which must not hold a migration open.
session_settingA session-level setting applied before the statements that follow it.
manual_gateA 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:

StrategyThe sequence
algorithm_lock_hintName the algorithm and lock level the engine would otherwise pick for you.
batched_backfillMove the data in bounded batches from a job rather than in one migration statement.
charset_migrationMove the column to the new character set without a full-table conversion in place.
concurrentlyBuild the index without taking the write lock the ordinary form takes.
deploy_window_dropDrop across two deploy windows so a rollback in between still finds what it needs.
drift_correctionA schema difference the drift comparison found, and the shape of putting it right.
enum_append_onlyExtend the enumeration by appending, never by rewriting its existing members.
expand_contractAdd the new shape, move readers and writers across, remove the old one — over three deploys.
explicit_identifierGive the object a name of your own, short enough for the engine — the fix for an identifier that goes over the limit.
noneLooked at, and there is no safe standard sequence — a statement, not an absence.
not_valid_then_validateAdd the constraint unvalidated, then validate it in a second migration.
reindex_before_refreshRebuild the indexes FIRST, and only then tell the server the recorded version is current.
rewrite_avoidanceReach the same end state without the operation that rewrites the whole table.
timeout_preambleBound how long the statement may wait and how long it may run before it is given up on.
transaction_splitGive 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_version than 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.