Correction workbench

Fix the ledger without editing it · 3 drafts · 2 awaiting approval · 6 posted today · every correction is an append with a backward pointer and no forward effect · Thu 30 July 2026 09:12 GST

A correction is an append. Never an edit, never a delete. The original entry keeps its row_hash, keeps its place in the chain and keeps its balance_after. The correction points backwards through correction_of_entry_id and nothing points forwards, so entries_rechained is 0 on every correction this workbench has ever produced. That column carries a CHECK constraint, so it is checkable rather than promised. Chain verify →

Build a correction

Find the entry, read it, describe what is wrong, dry run it, send it for a signature. Nothing between here and the signature writes anything.

Find a ledger entry

Search resolves SKUs through sku_aliases, so a renamed variant still finds its own history.
LEDG-2026-0729-044229-07 14:22
LBA01·HQ_319·rts_received+5
LEDG-2026-0729-043129-07 11:09
LBA01·HQ_319·rts_received+5
LEDG-2026-0729-041829-07 08:14
LBA01·MCC·transfer_in+10
LEDG-2026-0729-040229-07 07:48
LBA01·HQ_319·transfer_out−2
LEDG-2026-0728-037728-07 17:48
LBA01·HQ_319·sale−3
LEDG-2026-0728-034128-07 15:12
LBA02·MCC·sale−1
LEDG-2026-0727-029827-07 10:08
LBA01·HQ_319·grn_receipt+60
LEDG-2026-0726-044226-07 09:44
FYB01·HQ_319·correction−1
LEDG-2026-0725-038825-07 14:55
LBA01·MCC·display_out−1
LEDG-2026-0724-030124-07 18:30
LBA01·HQ_319·sale−2
10 of 214 shownJ K to move

Original entry

read-only · signed · immutable
Entry
LEDG-2026-0729-0442
SKU
LBA01 · Alyazia Handbag, Black and Tan
Location
HQ_319
Stock type
STOCK
Source
ops2 · rts_received
Actor
ops2-poller (bot, no human)
Delta
+5 units · AED 765 at layer cost 153.00
Balance after
51
Occurred
29-07-2026 14:22:07 GST
Recorded
29-07-2026 14:22:09 GST
Idempotency
ops2:rts:RTS-0022:line-4
Chain position
seq 2,481,904 of 2,481,911
prev_hash
sha256:c4e18f02 7a19b3d0 c5e82f14 a96b7d1e 3c0a9f82 bde41c7a 8f9d0e3b 2c1a7f6e
row_hash
sha256:9d4a3c11 f8e2a9d0 b7c6e5d4 c3b2a190 8f7e6d5c 4b3a2918 0f7e6d5c 4b3a2918
Both hashes are shown so an approver can confirm the entry has not moved. Neither will change when the correction posts. If either one ever did, the nightly verify would fail and that failure is a P1.
What posting a correction does to the chain
LEDG-…0431 · the first, correct RTS receipt · +5 · balance_after 46 …0431 +5 · 46 LEDG-…0442 · the duplicate the poller wrote · +5 · balance_after 51 · WRONG but untouched …0442 +5 · 51 LEDG-…0451 · a sale · −1 · balance_after 50 · unchanged, not rechained …0451 −1 · 50 LEDG-…0463 · a sale · −2 · balance_after 48 · unchanged, not rechained …0463 −2 · 48 The correction, appended at the tail · −5 · balance_after 43 · this is the only new row COR-0037 −5 · 43 correction_of_entry_id · a backward pointer only these three keep their row_hash and their balance_after nothing between …0442 and the tail is recomputed entries_rechained = 0
The v2 workbench reports entries_to_rechain: 4 at exactly this point and its banner says a correction "re-chains all downstream entries". Recomputing downstream hashes is a rewrite of the chain, and a chain that is rewritten in normal operation proves nothing about anything.

Proposed correction

draft
Kind *
Quantity delta *
units
was +5 units, AED 765→net 0 units, AED 0
A reverse offsets the original in full. The original stays at +5 forever; the pair of rows nets to zero when the balance is replayed.
Explanation *
From the closed explanation_catalog. This is what makes the root-cause Pareto on the drift queue mean anything, and what the detector-tuning loop reads.
Root cause tag *
Linked drift case *
Mandatory above the auto-accept cap of 2 units and AED 150. A correction with no case is a P1 at the Phase 8 gate, because it is a change to the ledger that nobody asked for.
Reason *
Visible to the approver, the audit log and every downstream consumer of the ledger feed.284 / 40 min
Evidence
ledger_slice · both entries photo · RTS stage sibling_state · ops2 RTS-0022
digest sha256:7b1ec92f 4a08d113 6e5f2b8c 90a1d3e7
The digest freezes what the approver will see. If any evidence row is added or removed afterwards, the digest changes, the subject version increments and every collected signature is invalidated.
T2 · one signature. Impact is AED 765, under the AED 5,000 threshold, and the correction is reversible. Requires role >= manager plus the drift_resolve feature. You are the requester, so you may not sign it. Routed to Ramesh Kumar.
Type CORRECT to confirm

Dry run

computed 09:11:58 · 38 ms · nothing written
correction COR-2026-0730-0037 kind reverse correction_of_entry_id LEDG-2026-0729-0442 unchanged by this operation sku_id 1041 display LBA01 location_id 1 display HQ_319 stock_type_id 1 display STOCK qty_delta −5 value_aed 765.00 layer PO-2026-104 at 153.00 movement_pair_id null no location change, so no pair required leg null drift_case_id DRF-0131 explanation_code poller_double_post idempotency_key correction:COR-2026-0730-0037 -- chain -- prev_hash sha256 1f8c…44b2 current tail, seq 2,481,911 this_hash sha256 3f9e41ab… computed, not yet written entries_rechained 0 CHECK (entries_rechained = 0) rows_updated 0 this operation issues no UPDATE at all rows_deleted 0 cache_writes 0 the projection follows the ledger -- balance for (LBA01, HQ_319, STOCK) -- balance_before 48 balance_after 43 cache_is_reconciled true → true shopify_divergence −5 → 0 the reason this correction exists -- downstream -- drift_case DRF-0131 → resolved on post, with this entry named fanout 1 balance event to ops2, CSD, SKU Intel verification scheduled T+5m re-reads the key, reopens on failure

The dry run is frozen and shown again to the approver, so the person signing sees the same numbers the person requesting saw. If the ledger moves in between, the digest no longer matches and the request goes back for a fresh dry run rather than posting against stale arithmetic.

Relocate pair preview

COR-2026-0730-0038 · from CNF-0003

A correction that changes location is never one row. Both legs share a movement_pair_id and a deferred constraint trigger asserts the pair sums to zero at commit, so a half move cannot be written even by a crashed process.

LegLocationCounterpartStock typeΔBalance after
outHQ_319HQ_325STOCK−153
inHQ_325HQ_319STOCK+118
Pair sum, asserted at commit0trigger passes
movement_pair_id
8f2a41c7-9e03-4b1d-a5f6-2c810b7e4d99
Source
CNF-0003 · immutable RFID read
Explanation
unrecorded_transfer
source_type
correction
Not
transfer
INV1's relocation reconciler gets the mechanism right and the label wrong: it posts immutable pairs, which is correct, but tags them source_type='transfer'. That makes roughly 4,500 units of correction indistinguishable from real trucks, so every transfer count, lead-time average and utilisation figure silently includes them. i3 tags corrections as corrections and the transfer measures exclude them by construction.

What this workbench refuses to do

Each refusal is a constraint in the schema, not a check in a handler that somebody can forget

6 refusals
UPDATE an entry
There is no code path that issues an UPDATE against ledger_entries. A wrong row stays wrong and a new row says so. This is the whole point of an append-only journal, and it is what makes the hash chain worth computing.
events in, never edits
DELETE an entry
Same. A deleted row takes its balance_after with it and breaks every replay that crosses it.
events in, never edits
Recompute a downstream hash
entries_rechained carries CHECK (entries_rechained = 0), so the claim is enforced rather than documented. The nightly verify independently confirms that no historical row_hash changed, including on days when corrections posted.
corrects the v2 workbench
Change a location in one row
A relocate without a movement_pair_id, a counterpart location and a leg fails its CHECK. The deferred trigger then refuses a pair that does not sum to zero. The 1,205 cross-location defect is not an attribution bug, it is 1,205 unrecorded transfers, and its structural cause is a ledger that permits a lone row to change a location.
L26
Post a zero-quantity correction
CHECK (qty_delta <> 0). A landed-cost revision moves value with zero quantity and belongs in cost_ledger, a second hash-chained journal. Weakening the constraint would mean every quantity aggregation in all fifteen modules has to remember to filter cost rows out, and forgetting once produces a silently wrong total.
L14
Write to the balance cache
No module 13 code path writes inventory_balance, and CI greps for it. The before-and-after figures on this page are reads. i2 stored a cached quantity and never compared it to anything, which is mechanically how sellable reached minus 140,753; the fix is not a better repair tool, it is having no repair tool at all.
L8

One more refusal that is a policy rather than a constraint: a bulk external reconciliation against a counted file or a supplier statement is a proposal set, not a posting. It opens one case per key, groups them by explanation, and presents the whole set as a single approval subject. INV1's equivalent posts a row per differing key with created_by = None, a constant note, and occurred_at hardcoded to one instant at year end, then rebuilds the balance table from scratch. 4,456 rows arrived that way and not one of them has a signature.

A correction that could not have been refused is a correction nobody had to think about. Every one of the six above has a live example in the incumbent systems.

The invariant this page enforces

Never an edit, never a delete. A correction is a new row at the tail of the chain with a backward pointer. The original entry, its hash, its position and its balance_after are all exactly as they were.

The reason is part of the chain. A 40-character minimum, a closed explanation code, a closed root-cause tag, a linked case above the cap, and at least one evidence row with a digest. The digest binds the approver's signature to what they actually saw.

The approver is never the requester. Enforced by a trigger on the signature table, generalised from the same rule module 09 applies to write-offs.

Where a correction can come from

Four sources, one shape

SourceLink field30d
Drift casedrift_case_id31
Conflictconflict_case_id6
Correcting a correctionsupersedes_correction_id2
Auto-accept batch reversalauto_accept_batch_id0
No sourcenone0

The last row must always read zero. A correction with no source is a change to the ledger that nobody requested, and the Phase 8 gate report treats one as a P1.

Recent corrections

14 shown of 187 · every row names its posted entry, its explanation and its signatures

6 today
All 187 have entries_rechained = 0 and a non-null posted entry where state is posted.
Correction When Kind Original entry Key Δ AED Reason, truncated Signatures Posted entry State
COR-003630-07 08:44reverseLEDG-0730-0071FYB01 · HQ_319−1375Shopify webhook replayed after a CDN retry, duplicate fulfilmentRKLEDG-0730-0088Posted
COR-003530-07 07:20relocatenone · pairMIN-TBL-014 · TRANSIT01,180Truck arrived at MCC, receive scan never taken, closes DRF-0104AY + FALEDG-0730-0062 pairPosted
COR-003429-07 14:38shrinkagenoneMIN-CNL-019 · HQ_319−101,800Seventh manual adjust on this key in 30 days, held pending the bay F recountRK pendingnot yetAwaiting
COR-003329-07 13:02reclassLEDG-0729-0388MIN-MRR-003 · MCC0205Return arrived and was shelved without inspection, STOCK to QUARANTINEFZLEDG-0729-0401Posted
COR-003229-07 11:41partialLEDG-0729-0344PP02 · HQ_319−2288GRN booked 48, counted 46, supplier confirmed a short pick of 2RKLEDG-0729-0359Posted
COR-003129-07 11:20reclassnone · pairLBA01 and LBA02 · MCC0155Blind counter confused two colours of the same bag, net family delta −1FZ pendingnot yetAwaiting
COR-003028-07 21:33reverseLEDG-0728-0290JRY01 · AJM−2312Stock-count bot wrote +2 against the wrong SKU, JRY01 should be JRY03KSLEDG-0728-0311Posted
COR-002928-07 18:47relocatenone · pairLBA01 · HQ_319 to MCC0306Display allocation moved 2 units to MCC floor without a transferAYLEDG-0728-0272 pairPosted
COR-002828-07 16:02reverseLEDG-0728-0201TNS01 · YAS−147Transfer leg rejected at YAS receive, the unit never left HQ_319ruleLEDG-0728-0219Posted
COR-002728-07 14:40shrinkagenoneJAE17 · HQ_319−2470Bay C cycle count, 2 tags never re-read in 30 days, confirmed lostRK + FALEDG-0728-0188Posted
COR-002628-07 11:22reverseLEDG-0727-0455MIN-VAS-022 · CCZ+1180Cancellation webhook fired twice, the second one restocked a unit that was never returnedFZLEDG-0728-0140Posted
COR-002527-07 19:09shrinkagenoneMIN-CNL-019 · HQ_319−4720Rejected: sixth shrinkage on the same key, the pattern is upstream not physicalAY rejectnoneRejected
COR-002427-07 17:15reclassLEDG-0727-0388LBA03 · MCC0155Superseded by COR-0031 after the family check showed the pair, not one SKUAYLEDG-0727-0402Superseded
COR-002327-07 09:50foundnoneLLW-BAG-047 · HQ_319+32,670Rejected: supplier over-ship needs a GRN amendment, not a found-stock postingFA rejectnoneRejected
14 of 187 · 2 rejected, 1 superseded, 2 awaiting · every posted row names the entry it produced COR-0028 is the only rule-signed row here: 1 unit at AED 47, negative, reversible, within budget.

State variants

Six of the eight states this page can render

Pattern library ↗
Loading

The hash boxes render last and the submit button stays disabled until both hashes are present. Signing an entry whose hash has not loaded is signing a description.

Empty

Nothing needs correcting.

No drafts, nothing awaiting a signature, and no open drift case has a proposed entry. The workbench is meant to be empty; a busy one is a symptom.

Drift queueCorrection history
Error

Dry run failed.

The balance replay for this key timed out at 8s. The form is preserved and the submit button is disabled, because a correction submitted without a fresh dry run would be signed against arithmetic nobody computed.

Error id lcr-2b90f4 · 30-07-2026 09:10:02 GST · nothing was written
Service health
Zero-filter

No entries match.

4 filters are hiding all 214 entries for this SKU.

SKU: LBA01location: BAStype: rts_received24-07 to 30-07
Permission

You can read entries but not correct them.

Proposing a correction needs role >= manager plus the drift_resolve feature. The search and the original entry stay readable, because reading the chain is how anyone checks it.

apps.i3.role = viewer · features = [read]
Request accessWho can do what
Maintenance

Read-only for 25 minutes.

The nightly hash-chain verify holds the tail, so nothing may append. Drafts save and dry runs compute; posting is queued until 21:25 and the dry run re-runs before it does, because the tail will have moved.

21:00 to 21:25 GST · job chain-verify-0730 · 2,481,911 entries to verify
Connected to Drift queue Case detail Conflict inspector Approvals Anomaly explorer Audit log SKU detail Location detail Live inventory Transfers Return to supplier GRN and QC Stock count Damages Incidents Cost and price Reports Dev console