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-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

FieldRequiredWhat it is
schema_versionyesAlways 1 for this shape. Check it first — see below.
stabilityyesConstant for the whole version-1 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.
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.

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.
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'] > 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_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, 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.