Remediations

A diagnosis that ends at “you should probably” is half a product

Decim proposes the change, links it to the evidence that justified it, and improves it as review comes back. Nothing is ever merged automatically.

Why a diagnosis is not enough

Knowing the cause and knowing what to do about it are separated by a surprising amount of work — usually done by whoever is most tired.

The worked investigation ends with a clear finding: the mapping had no entry for CONTACTLESS, and 455,382 rows were diverted because of it. That is genuinely the hard part. It is also, from the perspective of the person on call, the beginning of the second half.

They now have to find which of possibly several copies of that mapping is the one the service reads. They have to decide what the new code should map to, which turns out to be a question about reporting rather than about code. They have to work out whether the already-rejected rows can be replayed safely, and in what order. And they have to do all of it at whatever hour the report surfaced.

Every one of those steps is a place to introduce a second incident. The point of a proposal is not that a machine is better at these decisions — it plainly is not — but that the mechanical parts arrive already done and the judgement calls arrive already identified as judgement calls.

Kinds

Not every fix is a pull request

A file replay is not a code change, and dressing it up as one helps nobody.

There is a strong pull toward making everything a diff, because diffs are reviewable and familiar. It produces nonsense — a pull request that "changes" a scheduler window that lives in msdb, or one that claims to replay rows it cannot touch.

Remediation kinds and how each is delivered
Kind Delivery Detail
Code changePull request, draft by defaultA branch, a diff, a rationale citing evidence ids
Data changePull request and a flagged second copyThe mapping row that was missing — in the place the pipeline actually reads
ConfigurationProposed change, no diffA scheduler window moved, a retry policy narrowed — there is no code to change
Operational actionRunbook onlyReplay these rows, in this order, with these checks. Proposed, never performed
Proposal

A real proposal

The diff for the incident above, and the description that accompanies it.

The diff is small, which is typical — most ETL incidents resolve to a change of a few lines, and the difficulty was never the typing.

config/tender-map.json diff
diff --git a/config/tender-map.json b/config/tender-map.json
index 4a1c9e2..b7f0d31 100644
--- a/config/tender-map.json
+++ b/config/tender-map.json
@@ -9,7 +9,11 @@
   "tenders": {
     "CASH": 1,
     "CREDITCARD": 2,
-    "VOUCHER": 3
+    "VOUCHER": 3,
+
+    // Upstream POS release 2026.8.0 (04 Aug) began reporting contactless
+    // payments as a distinct code. Previously folded into CREDITCARD.
+    "CONTACTLESS": 2
   },
   "onUnmapped": "reject"
 }

The description is where the work is. It cites evidence ids so a reviewer can open the artefact behind each claim, and it is explicit about the boundaries of what it is proposing.

Pull request description markdown
## What this changes

Adds `CONTACTLESS` to the tender map, resolving to tender id `2` — the same
id `CREDITCARD` uses today.

## Why

Between 02:14 and end of day on 05 Aug, 455,382 rows were routed to
`dbo.RejectedTransactions` with reason `unmapped PaymentType` and value
`CONTACTLESS`.  [ev_01J9Z4K2QX]

`config/tender-map.json` was last modified 11 days before the incident and
contains no `CONTACTLESS` key.  [ev_01J9Z4P1QC]

Upstream POS release notes for 04 Aug record contactless payments being
reported as a distinct code where they were previously folded into
`CREDITCARD`.  [ev_01J9Z4Q8XR]

## What this does NOT decide

Mapping `CONTACTLESS` to id `2` preserves the behaviour you had before the
upstream release — contactless and chip-and-pin remain indistinguishable in
reporting. If finance wants them separated, this needs a new tender id and a
decision about historical comparability instead. That is a business call and
this PR takes the conservative option deliberately.

## Also affected

`dbo.TenderMap` on sql-prod-02 holds a second copy of this mapping and is
also missing `CONTACTLESS`. `appsettings.Production.json` sets
`Mapping.Source: "File"`, so the database copy is not what executes — but it
disagrees with the file, and one of them is wrong. Not changed here; see
DRIFT-0031.

## Rows already rejected

This fixes forward. 455,382 already-rejected rows need replaying — proposed
separately as a runbook, not as code.
Judgement

The part it must not decide

The single strongest argument for draft-by-default is visible in that diff, and it is not a safety argument.

Mapping CONTACTLESS to tender id 2 is the conservative choice: it reproduces the behaviour that existed before the upstream release, when contactless payments were reported as CREDITCARD and counted as such. Nothing in the reporting changes, and the missing rows start landing again.

It is also possibly wrong. If the business wants contactless separated — and the upstream vendor evidently thinks it is a distinct thing, or they would not have split the code — then the correct fix is a new tender id, plus a decision about whether historical data gets restated. That decision has consequences for every report comparing this quarter to last, and no amount of evidence from the pipeline can settle it.

So the proposal takes the conservative option, states that it has done so, and says what the alternative would require. This is the difference between a tool that helps and one that quietly makes business decisions on your behalf at 3am — and it is why "just merge it automatically when confidence is high" is the wrong feature. Confidence in the diagnosis was high. Confidence in the fix is not the same quantity.

What is automated, and what is structurally not
Action Automated? Detail
MergingNeverDraft by default; a human approves every change
Writing to your databaseNeverThe agent holds read-only credentials with explicit DENY on write verbs
Replaying or requeueing rowsNeverProposed as a runbook with pre-flight and verification steps
Restarting a job or serviceNeverNo execution path exists
Opening a draft PRYesOn a branch, with a scoped token you issue
Revising its own PRYesIn response to review comments or failing checks, with the cause recorded
Replay

The operational half

Fixing forward stops the loss. It does not recover the 455,382 rows already sitting in the reject table.

Replay is proposed as a runbook rather than executed, and the runbook is mostly checks. A replay that runs against an incomplete mapping simply rejects the same rows a second time; one that deletes the reject rows before verifying leaves you with no evidence and no data.

Step 1 — pre-flight, must return zero rows sql
-- Pre-flight. Must return ZERO rows before any replay begins.
-- If it returns anything, the mapping is still incomplete and replaying now
-- would simply reject the same rows a second time.
SELECT DISTINCT r.RejectedValue
FROM dbo.RejectedTransactions AS r
LEFT JOIN dbo.TenderMap       AS m ON m.ExternalCode = r.RejectedValue
WHERE r.Reason        = 'unmapped PaymentType'
  AND r.RejectedAtUtc >= '2026-08-05'
  AND m.ExternalCode IS NULL;
Step 2 — stage in original batch order sql
-- Stage for replay in original batch order. The reject rows are NOT
-- deleted here: they stay until the reconciliation confirms the replay
-- landed, because a failed replay with the evidence already deleted is
-- unrecoverable.
INSERT INTO dbo.ReplayQueue (RejectedTransactionId, BatchId, QueuedAtUtc)
SELECT r.RejectedTransactionId, r.BatchId, SYSUTCDATETIME()
FROM dbo.RejectedTransactions AS r
WHERE r.Reason        = 'unmapped PaymentType'
  AND r.RejectedValue = 'CONTACTLESS'
  AND r.RejectedAtUtc >= '2026-08-05'
  AND r.RejectedAtUtc <  '2026-08-06'
ORDER BY r.BatchId, r.RejectedTransactionId;
Step 3 — verify the arithmetic closes sql
-- Verify. The day's arithmetic must close before the reject rows are
-- retired. Anything other than zero means stop and re-investigate.
SELECT
    b.ReceivedCount,
    COUNT(DISTINCT t.TransactionId)         AS Loaded,
    COUNT(DISTINCT r.RejectedTransactionId) AS StillRejected,
    b.ReceivedCount
      - COUNT(DISTINCT t.TransactionId)
      - COUNT(DISTINCT r.RejectedTransactionId) AS Unaccounted
FROM dbo.IntakeBatch               AS b
LEFT JOIN dbo.[Transaction]        AS t ON t.BatchId = b.BatchId
LEFT JOIN dbo.RejectedTransactions AS r ON r.BatchId = b.BatchId
WHERE CONVERT(date, b.ReceivedAtUtc) = '2026-08-05'
GROUP BY b.ReceivedCount;

The ordering is the substance. Reject rows survive until step three confirms the replay landed, because the alternative — clearing them first to keep the table tidy — is unrecoverable if anything goes wrong. Replaying in original BatchId order matters wherever downstream aggregation is sensitive to sequence, which is more often than people expect.

Revisions

Revisions, with stated causes

A revision with no stated cause is indistinguishable from a rewrite at random.

  1. 1

    v1 — the initial proposal

    Derived directly from the diagnosis, with every claim in the description citing an evidence id. At this point it is a hypothesis about the fix, and it is labelled as one.

  2. 2

    v2 — after a review comment

    A reviewer points out that onUnmapped: "reject" is the setting that made this silent in the first place, and asks whether it should become alert. The revision records the comment, the change, and — importantly — that this widens the PR beyond the original diagnosis.

  3. 3

    v3 — after a failing check

    CI fails on a test asserting that unknown tender codes are rejected. The test encodes the old behaviour deliberately. The revision states this as its cause and proposes updating the test rather than reverting the change, because a test that pins a bug is still a decision someone made.

  4. 4

    v4 — after new evidence

    A later investigation finds GIFTCARD_V2 arriving in small numbers from the same upstream release. The revision extends the mapping and cites the new evidence, rather than opening a second PR that would conflict with this one.

What makes this a record rather than a changelog is that each entry names its cause, and the causes are of different kinds: a human objection, a mechanical failure, and new evidence. Those warrant different amounts of scepticism from a reviewer. A change prompted by a failing test is a fact; a change prompted by a review comment is a negotiation; a change prompted by new evidence should send you back to the investigation to check whether the diagnosis still holds.

Notice also what v2 does. The reviewer's suggestion — that onUnmapped should alert rather than silently reject — is a better fix than the one proposed, and it addresses the class of failure rather than this instance. The revision records that it widens scope beyond the original diagnosis, because a pull request that grows without anyone noticing is its own kind of problem.

Common questions

Will it merge anything automatically?
No, and there is no configuration flag that enables it. Pull requests are opened as drafts on a branch, using a token you issue and scope. The value is in the proposal being specific and evidence-linked, not in removing the human — and a system that could merge would need write access to production behaviour, which is exactly what the agent's permission model is built to withhold.
What stops it proposing a change that makes things worse?
Review, mostly — the same control you already apply to changes from people. Two design choices help: a proposal cites the evidence that motivated it, so a reviewer can check the reasoning rather than only the diff; and where a fix embeds a judgement call, the description says so explicitly instead of quietly picking one.
Can it fix the data, not just the code?
It can propose the data change and show you exactly where it needs to go — including which of two disagreeing copies is the one that actually executes. It cannot apply it. Rows already lost are handled as a replay runbook with pre-flight and verification steps, because a bad replay is considerably worse than no replay.
Does it need write access to our repository?
It needs permission to push a branch and open a pull request, on repositories you nominate. It does not need write access to protected branches and should not be granted it. Nothing about the design requires the ability to merge.
What if we do not use pull requests?
The proposal exists regardless — a diff, a rationale and its citations — and you can apply it however you normally do. The pull request is a convenient delivery mechanism for teams that already review that way, not a requirement.
Do the proposals actually improve over time?
Each revision records what prompted it, which is the part that makes the claim checkable rather than marketing. A revision with no stated cause is indistinguishable from a rewrite at random, so the record is the feature. Across incidents, what accumulates is which proposals were accepted, altered or rejected — which is also the calibration input for confidence scoring.

Related: investigations for how the diagnosis is reached; evidence for what the cited ids resolve to; the agent for why it cannot write to your database; and custom ETL services for the configuration patterns that produce this failure.

Get started

Bring us a pipeline that broke last week

The fastest way to evaluate this is a real incident you already know the answer to. If Decim gets it wrong, that is a far more useful demo than one where it doesn't.