Summary
Every inventory decrement is check-then-act with an absolute SET on save, and bin_contents has no concurrency token. Concurrent issues against the same bin content lose writes: the movement ledger records every issue, but on-hand only reflects the last writer.
Reproduction (beta.25 image, isolated stack, Engineer kiosk)
POST /api/v1/inventory/receive-stock {partId, quantity: 100}
- Fire 4 concurrent
POST /api/v1/inventory/use-stock {partId, quantity: 30}
- All four return
204
GET /api/v1/parts/{id}/inventory-summary → totalQuantity 40 (120 issued, 60 deducted)
GET /api/v1/inventory/movements lists all four Issue movements of 30 plus the Receive of 100, so the ledger says 120 left a bin that held 100 while on-hand reads 40.
Same shape on the ERP-proper path: 4 concurrent POST /api/v1/jobs/{id}/material-issues of 30 against 100 on hand → four 201s, four MaterialIssue rows, final on-hand 70. Three writes lost.
Two barcode scanners issuing the same part at the same moment reproduce this on a real floor.
Where
All decrements follow the same pattern (existing.Quantity -= data.Quantity after a read-and-compare):
forge.api/Features/Inventory/UseStock.cs (~L63-74)
forge.api/Features/Jobs/CreateMaterialIssue.cs (~L62-71)
forge.api/Features/Scanner/ExecuteScanIssue.cs (~L78-83)
forge.api/Features/Inventory/TransferStock.cs (~L44), AdjustStock.cs (~L41), SetOnHandQuantity.cs (~L86), CreateReservation.cs (~L37-56)
Mobile/MoveStock.cs delegates to TransferStockCommand, so /api/v1/mobile/stock/move inherits it
BinContent derives from a bare BaseEntity; BinContentConfiguration declares no IsRowVersion/IsConcurrencyToken; forge-db/schema/tables/bin_contents.sql has neither a version column nor a CHECK (quantity >= 0).
The codebase already applies the right pattern elsewhere: SELECT … FOR UPDATE in ForgeGlPostingEngine.cs, FiscalPeriodCloseService.cs, ApproveVendorBill.cs; FOR UPDATE SKIP LOCKED in Leads/Queue/PullQueueHandler.cs. It is simply never applied to bin_contents.
Suggested fix
- Row version (
xmin as a concurrency token, or an explicit version column) on bin_contents, with the standard retry-on-DbUpdateConcurrencyException in the decrement handlers, or SELECT … FOR UPDATE on the bin content row inside the decrement transaction.
CHECK (quantity >= 0) and CHECK (reserved_quantity <= quantity) on bin_contents as the last line of defence.
- A concurrency test that fires N parallel
use-stock calls and asserts on-hand equals the ledger.
Found by the 2026-08-30 arsenal-as-client gap audit; every claim above was reproduced live and survived adversarial verification.
Summary
Every inventory decrement is check-then-act with an absolute SET on save, and
bin_contentshas no concurrency token. Concurrent issues against the same bin content lose writes: the movement ledger records every issue, but on-hand only reflects the last writer.Reproduction (beta.25 image, isolated stack, Engineer kiosk)
POST /api/v1/inventory/receive-stock{partId, quantity: 100}POST /api/v1/inventory/use-stock{partId, quantity: 30}204GET /api/v1/parts/{id}/inventory-summary→totalQuantity40 (120 issued, 60 deducted)GET /api/v1/inventory/movementslists all four Issue movements of 30 plus the Receive of 100, so the ledger says 120 left a bin that held 100 while on-hand reads 40.Same shape on the ERP-proper path: 4 concurrent
POST /api/v1/jobs/{id}/material-issuesof 30 against 100 on hand → four201s, fourMaterialIssuerows, final on-hand 70. Three writes lost.Two barcode scanners issuing the same part at the same moment reproduce this on a real floor.
Where
All decrements follow the same pattern (
existing.Quantity -= data.Quantityafter a read-and-compare):forge.api/Features/Inventory/UseStock.cs(~L63-74)forge.api/Features/Jobs/CreateMaterialIssue.cs(~L62-71)forge.api/Features/Scanner/ExecuteScanIssue.cs(~L78-83)forge.api/Features/Inventory/TransferStock.cs(~L44),AdjustStock.cs(~L41),SetOnHandQuantity.cs(~L86),CreateReservation.cs(~L37-56)Mobile/MoveStock.csdelegates toTransferStockCommand, so/api/v1/mobile/stock/moveinherits itBinContentderives from a bareBaseEntity;BinContentConfigurationdeclares noIsRowVersion/IsConcurrencyToken;forge-db/schema/tables/bin_contents.sqlhas neither a version column nor aCHECK (quantity >= 0).The codebase already applies the right pattern elsewhere:
SELECT … FOR UPDATEinForgeGlPostingEngine.cs,FiscalPeriodCloseService.cs,ApproveVendorBill.cs;FOR UPDATE SKIP LOCKEDinLeads/Queue/PullQueueHandler.cs. It is simply never applied tobin_contents.Suggested fix
xminas a concurrency token, or an explicitversioncolumn) onbin_contents, with the standard retry-on-DbUpdateConcurrencyExceptionin the decrement handlers, orSELECT … FOR UPDATEon the bin content row inside the decrement transaction.CHECK (quantity >= 0)andCHECK (reserved_quantity <= quantity)onbin_contentsas the last line of defence.use-stockcalls and asserts on-hand equals the ledger.Found by the 2026-08-30 arsenal-as-client gap audit; every claim above was reproduced live and survived adversarial verification.