management: numeric bounds sit on the transports, so the WhatsApp bot writes inventory unbounded from LLM/OCR output #71
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by
Reviewer68r3during round 3 of PR #68, by enumerating from the service layer rather than from the diff. This is the reason the write-path count in that PR was wrong four times in a row — four, then six, then ten — and it will keep being wrong until the bound moves.The structural mistake
Every bound added by PR #68 is attached to a transport: a REST schema, an MCP tool registration. None is attached to the service that writes the column. So each service has callers the bounds do not cover.
Each of the three services that write these columns has exactly three callers:
adjustStock(inventory.stock)create_stock_adjustmentAdjustmentDeltaSchemacreateStockAdjustmentexecute-stock-adjust.ts:19createPurchase(stock,buy_price)POST /purchases, MCPcreate_purchase/update_purchasecreatePurchaseWithInventory/updatePurchaseexecute-purchase.ts:50createOrder(stockvia FIFO,priceSold)POST /orders, MCPcreate_ordercreateOrderWithItemsexecute-order.ts:39Measured by calling the services exactly as the bot nodes call them, every statement inside
begin/rollback:That is the same raw
Failed query:PR #68 was widened to eliminate — one layer below the schemas that were supposed to end it.Why the bot path is the one that matters
It is live, not hypothetical, and not deferred.
POST /api/whatsapp/webhook(app/api/[[...route]]/whatsapp.ts:32) is api-key gated and fireshandleWebjsWebhook→runBotMessage→ the graph, which routesADJUST_STOCK→confirm→execute_stock_adjust(graph.ts:113). Theconfirmnode validates no numbers.And the numbers are model output, unbounded at every hop.
lib/bot/nodes/classify-intent.ts:56-57, 82-83, 89declaresqty,buyPrice,priceSoldanddeltaas barez.number()— no.int(), no.max().The realistic route is not a crafted message, it is receipt OCR.
lib/bot/nodes/vision-llm.ts:20-21types a scanned line asquantity: z.number(), unitPrice: z.number(), and:91-92maps them straight ontoqty/buyPrice→createPurchase→inventory.buy_price. A smudged or mis-read total on a photographed receipt is the ordinary failure mode of OCR, not an edge case — and it lands on the cost column that every COGS and profit figure is computed from.So the untrusted input here is not a hostile user. It is a language model reading a phone photo of a receipt, and nothing between it and an int4 column checks the magnitude.
Scope
Bind at the service layer, where all callers route through. That is the fix the four wrong counts point at: bounding transports means every new transport must remember, and three already forgot.
adjustStock,createPurchaseandcreateOrdershould each validate their own numeric inputs against the sameMAX_RUPIAHceiling, reusing the single-source constant atlib/currency.ts:24. No new2147483647literal.classify-intent.tsandvision-llm.tsshould declare.int()and a ceiling on every quantity and price.Explicitly not this issue
createStockAdjustmentserver action having no zod at all, plus the adjustment modal previewing a change it cannot perform.adjustStockunder #69.Verification
adjustStock,createPurchaseandcreateOrderdirectly, as the bot nodes and server actions do — bypassing every transport — with a 10-digit value, a fractional value, a negative value and zero. Assert a readable rejection, not a rawFailed query:, and assert the row is untouched. Use a full-tablemd5(string_agg(...))plus row counts onpurchase_items,inventory_lots,stock_movements,purchasesandorders: a purchase write touches four tables, so "returns an error" and "left nothing behind" are different claims.unitPrice, and confirm the operator is told rather than the line being silently dropped.Acceptance
No caller of
adjustStock,createPurchaseorcreateOrder— REST, MCP, server action, or bot — can produce a raw driver error or write an out-of-range value toinventory; the bound lives at the service layer so a new transport inherits it; the bot's own LLM and OCR schemas declare integer bounds;MAX_RUPIAHremains single-source; and the caller enumeration is recorded here so the next reader does not have to re-derive it.