Vizen Shop

Stock: the number, the ledger and the rounds of a cancelled order

/docs/stockdraftEN· проверено 2026-08-20

Резюме по-русски. Склад в Vizen — это ЧИСЛО на товаре плюс ЖУРНАЛ движений рядом с ним. Остаток NULL значит «учёт не ведётся» (услуга, цифровой товар, изделие под заказ), а не «ноль штук»: без этого различия каталог продавца, который склад не заводил, оказался бы целиком распродан. Журнал объясняет число, но НЕ восстанавливает его: включение учёта движения не пишет, и Σ дельт с остатком не сходится — сверять надо по balance_after последнего движения. Продавец двигает статусы заказа как кнопки, поэтому у пары (заказ, товар, причина) есть ещё и КРУГ «отмена → снова в работу»: без него вторая отмена молча не возвращала товар. Подарок списывается со склада наравне с купленным, и наличие — такое же условие его выдачи, как публикация; а если подарочных единиц не хватило уже НА СПИСАНИИ, снимается подарок, а не заказ — §9. То же и на ПЕРЕОТКРЫТИИ отменённого заказа (решение владельца №6 от 2026-08-20), с одним отличием: там строка подарка остаётся в заказе историческим фактом — снимок состоявшегося заказа не переписывают. ⚠️ Три ручки этой области закрыты для персонального токена (PAT) — §2.

Status: draft · Verified: 2026-08-20 — 13 integration scenarios over a real Postgres (tz_m5), 4 existing + 9 written for this document, plus SQL measurements against the resulting data · Owner: promo line (Track: promo) Serves: GET /docs/stock — registered 2026-08-20 by the agent-api line; the copy the server embeds is kept byte-equal to this file by TestAreaCopiesInSync. Skill: none yet · Machine reference: none yet — see §13, this is the largest single gap of this document.


1. What you can do here

Turn stock tracking on for a product and set the number, stop tracking it, set the "running low" threshold, read the movement ledger of one product ("where did five units go"), and list what is running out across the shop. Everything else that touches stock happens to you, not by you: an order takes units off, a cancellation puts them back, reopening a cancelled order takes them off again, and a gift line is taken off exactly like a paid one — except when it has run out, where a shortage on the *gift* drops the gift instead of the operation. At checkout the gift row is deleted; on a reopen it stays in the order as a historical fact and is simply not reserved again (§9). A shortage on a paid line still fails both.

You never write a movement directly. There is no POST /stock-movements. The ledger is a consequence of two things only: the seller setting a number, and an order changing status.

2. Decide first (forks)

If you want…Take this pathCost
Manage stock with a personal API tokennot possible todaySetProductStock, GetProductStockHistory and GetLowStock are absent from services.DefaultMethodScopes, and that map is default-deny (guard.go:851-854). A PAT gets 403 PAT_METHOD_NOT_ALLOWED. Measured by `grep 'SetProductStock\GetLowStock\GetProductStockHistory' internal/core/services/guard.go` → 0 hits. Use a cabinet session (JWT) — everything below assumes one.
Set the number of unitsPUT /products/{id}/stockThe only path that writes a ledger row. Takes the product row under FOR UPDATE.
Set only the thresholdeither PUT /products/{id}/stock with low_threshold alone, or PUT /products/{id} with stock_low_thresholdSame result, different blast radius: the stock endpoint also erases the threshold when you omit it (§10, row 2). The product endpoint touches only what you send. Prefer PUT /products/{id} for threshold-only edits.
Set the number from the product formyou cannot — stock_quantity on PUT /products/{id} is accepted and ignoredDeliberate: the form sends the value it had when it was opened, and everything sold since would be overwritten silently and past the ledger (edit_product.go:512-522). The proto comment on the field still describes the ignored behaviour as working — §14, D6.
A product that never runs out (service, digital good, made to order)leave stock_quantity unset, or send quantity: -1 to clear itNothing checks it anywhere: cart, checkout, gift eligibility and the low-stock list all skip it. This is the default state of every new product.
Know who moved the stocknot available through the APIstock_movements.created_by is written but the StockMovement message has no such field (catalog.proto:1741-1754). The ledger answers "what happened", not "who did it".
Rebuild the number from the ledgerdo not — it does not add up§7, identity 2. Read stock_quantity, or balance_after of the newest movement.
Reserve stock at "add to cart" timenot possibleThe cart refuses a product already at zero, but does not check the amount and holds nothing. Only checkout takes units off. §10, row 8.

3. The main fact

`stock_quantity: null` means "this product is not tracked", not "zero left".

Everything in this area follows from it. An untracked product is always purchasable, never appears in the low-stock list, produces no ledger rows, returns nothing on a cancellation, and is still handed out as a gift. There is no separate "track stock: yes/no" switch — the presence of the number *is* the switch, which is why clearing it needs its own signal (a negative quantity, §4). It also means stock_quantity: 0 is a real, meaningful state: sold out, tracked, visible in the low-stock list.

The second fact, which people get wrong more often: the number is the record; the ledger explains it but cannot rebuild it.

products.stock_quantity is the source of truth. stock_movements is written next to it, in the same transaction, and carries balance_after precisely so that a discrepancy can be investigated without replaying history. But the ledger is not complete: turning tracking on writes no row, and neither does clearing it. Sum the deltas of a product and you will not get its stock — measured, §7.

4. Objects and where they live

4.1 The number — products

ColumnMeaningWritten by
stock_quantityINTEGER, NULL = not tracked, >= 0 enforced by chk_products_stock_quantityPUT /products/{id}/stock; every order transaction
stock_low_thresholdINTEGER, NULL = warn only at zero, >= 0 enforced by chk_products_stock_thresholdPUT /products/{id}/stock, PUT /products/{id}

Both are returned on GET /products/{id} and GET /products (stock_quantity only in the list), together with a server-computed in_stock. in_stock is domain.InStock(qty, 1): true when untracked, true when >= 1, false only when tracked and zero. The storefront must not re-derive it — the rule "untracked means always available" lives in one place on purpose.

Both columns are excluded from row versioning and from the dev/prod overlay (migration 0089): stock is an operational value of the live contour. Two defects paid for that line — every sale used to bump products.row_version and give the seller a false publish conflict, and publishing a draft used to resurrect the stock frozen when the row was forked.

Consequence, verified by reading `internal/api/catalog/stock.go`: there is no dev branch in any of the three handlers (grep -c 'contourEligible\|devOverlay\|dc.dev'0). A session that edits drafts everywhere else writes stock straight to the live product.

4.2 The ledger — stock_movements (migration 0084)

ColumnMeaning
idBIGINT identity. `int64` → a JSON string on the wire.
company_idtenant, NOT NULL — every table knows its shop
product_idON DELETE CASCADE
order_idthe order that caused it; NULL for a manual edit
deltasigned, CHECK (delta <> 0); minus = left the shelf
balance_afterthe stock after this movement
reasonsee §4.3
roundthe "cancel → back to work" cycle, >= 0, added by migration 0116
commentTEXT; an empty string is stored as NULL (NULLIF)
created_attransaction timestamp — rows of one order share it
created_bythe actor. Not exposed by the API. For an order movement this is the *buyer*; for cancel/reorder the *seller* who moved the status; for manual the seller.

GET /products/{id}/stock/history returns id, delta, balance_after, reason, comment, order_id, created_at, newest first, ordered by created_at DESC, id DESC. The id tie-break is load-bearing: movements written in one transaction share created_at to the microsecond, so without it the order of an order row and its cancel row would be undefined.

4.3 Reasons

<!-- gate:reasons -->

reasonWritten whenWritten by
ordercheckout takes the units offCreateOrderFromCart
cancelthe order moved to *cancelled*, units come backrestoreOrderStock
reordera cancelled order was put back to work, units go out againdeductReopenedOrderStock
manualthe seller recounted the boxesSetStock
returnnothing writes it — the constant domain.StockReasonReturn has no non-test caller
importnothing writes it — the constant domain.StockReasonImport has no non-test caller

<!-- /gate -->

⚠️ This table is hand-written; no gate guards it yet (§13). Two claims in it are negations and are registered in §12.

Measured on tz_m5 after the full suite: cancel 11, manual 304, order 12, reorder 8, return 0, import 0. The database CHECK accepts return and import (a direct INSERT succeeds; 'wat' is refused with SQLSTATE 23514) — they are reserved, not live. The served OpenAPI contract lists `reason` as `order | cancel | return | manual | import` — it omits the one reason a reopened order actually produces and advertises two that never appear (§14, D5).

4.4 Why reorder is its own reason, and why round exists

The idempotency key of the ledger is (order_id, product_id, reason, round), a partial unique index WHERE order_id IS NOT NULL. The reason it grew twice:

  • Migration `0092` added `reorder`. The key was (order, product, reason). Reopening a cancelled order had to take the units off again, but a second order row for the same order collides with the first, and ON CONFLICT DO NOTHING drops it — silently. The stock would stay inflated by the whole order.
  • Migration `0116` added `round`. With exactly two reasons per order, the key covered exactly one cycle. The second cancellation of the same order wrote the same triple, was dropped, and the units never came back:

`` stock 5, order of 2 → 3; cancel → 5; back to work → 3; cancel → 3 (!) ``

Two units locked inside a cancelled order forever, and every further cycle locks that many more. The storefront shows "out of stock" because of an order that no longer exists.

round is counted from the opposite reason (stockMoveRound, pg/stock.go:158-191), not its own, and this is the subtle part. A cancellation's round is the number of reorder rows; a reopen's round is the number of cancel rows minus one. Using its own counter would break the double click: "cancelled → cancelled" is a legal transition, it reaches the stock layer in full, and the second pass would compute the *next* round — a new key, a new row, and the units would come back twice. The opposite counter does not move on a repeat, so the second pass computes the same round and hits the unique index, which is exactly what it is for.

The same choice gives the right behaviour at the edge: a reopen that fails for lack of stock rolls back whole, leaves no reorder row, and the next cancellation therefore computes the *previous* round instead of inventing a return of units nobody took.

The round is computed under the product row lock and before the insert, so two simultaneous "Cancel" clicks cannot read the ledger at the same instant, disagree about the round and return the units twice.

Backfill: none, deliberately. DEFAULT 0 gives old rows exactly the round the new code would have written (the first cycle is zero).

4.5 The write path — ApplyStockMoves

Order-side movements are applied inside someone else's transaction (pg/stock.go:41), and that is the point: deducting stock must be atomic with creating the order. Deduct-then-fail would make goods vanish without going to anyone; insert-then-fail-to-deduct would place an order on goods that are gone.

Per product, in order:

  1. mergeStockMoves collapses all moves of one product into one, preserving first-seen order (the ledger is read by a human, so it follows the order lines).
  2. Moves with delta == 0 are skipped.
  3. SELECT stock_quantity … FOR UPDATE on the product row. For a return (delta > 0) the deleted_after IS NULL filter is dropped: an order holding a deleted product must still be cancellable, otherwise a strict check would fail the whole status transition and the order would be stuck forever. A return for a product that no longer exists at all is skipped, not an error.
  4. stock_quantity IS NULL → skip. Untracked products produce no ledger rows.
  5. after = stock + delta; after < 0domain.ErrOutOfStockFailedPrecondition out_of_stock. Unless the move carries gift units (StockMove.GiftUnits): then the paid part is deducted, the gift part is not, the product id is returned to the caller as a withheld gift, and the operation continues. If the paid part alone does not fit either, it is ErrOutOfStock as before — the degradation is about the free line only. Two callers set GiftUnits and both take the returned list: checkout, from the gift lines it is about to insert, and reopen, from the gift lines the order already has (§9). They differ only in what they do with a withheld product — checkout deletes its order_items row, a reopen keeps it.
  6. Insert the movement first, then update the number. The unique index is the only thing that makes a repeat safe: no row inserted means this is a repeat, and the number must not be touched.

Why the merge matters for money. One product can reach the stock layer as two lines — a paid line and a gift line, or the same product in two 3D configurations. Two rows would carry the same idempotency key, the second would be dropped by ON CONFLICT, and one unit would leave the shelf instead of two. Summing per product makes the key unique by construction. Measured: a cart with two lines of the same product (2 + 3) produces exactly one movement, order/-5.

⚠️ mergeStockMoves merges on ProductID only and keeps the first move's Reason, Comment and Author. Every caller today builds a batch with one uniform reason, so nothing is currently wrong — but a future caller mixing reasons in one batch would have them silently collapsed under the first. §14, D7.

5. Setting the number — PUT /products/{id}/stock

{ "quantity": 12, "low_threshold": 3 }

Three states have to fit into one optional integer, so the sign carries meaning:

quantityEffect
omittedthe number is not touched (setQty == false)
>= 0the number is set; 0 is a legitimate "sold out"
< 0tracking is cleared (stock_quantity = NULL)

A *int alone cannot separate "not sent" from "clear it", which is why SetStock takes a separate setQty flag — clearing tracking by accident is a way to sell what does not exist.

low_threshold follows the same sign rule (< 0 clears it) but has no "not sent" state — see §10, row 2. This asymmetry is a defect, not a design.

A ledger row is written only when the number really changes and tracking was already on:

if setQty && quantity != nil && current != nil && *quantity != *current { … }

Turning tracking on, clearing it, and re-setting the same number all write nothing. Turning tracking on and off is a change of mode, not a movement of goods; a row saying "12 units arrived" would be a lie about the warehouse.

Response: quantity, low_threshold, in_stock — re-read from the product, not echoed from the request.

6. Reading

  • GET /products/{id}/stock/history?limit=N — the ledger of one product, newest first. Tenant comes from the session, never from the request: otherwise it would be a way to read other shops' warehouses by product id. A foreign shop gets an empty list, not an error (measured). A user with no shop gets PermissionDenied forbidden.
  • GET /products/low-stock?limit=N{product_id, name, quantity, low_threshold} for products where stock_quantity IS NOT NULL AND stock_quantity <= COALESCE(stock_low_threshold, 0), ordered by stock_quantity, name, id. The id tie-break keeps the list stable across calls when stock and name collide. Drafts are included; only soft-deleted products are excluded.

Without a threshold only zero is reported. With a threshold of 10 the cabinet would otherwise nag about every product that has "only ten".

7. Identities

<!-- gate:identities -->

1.  stock_quantity  ==  balance_after of the newest movement of that product
       — holds whenever the product has at least one movement AND tracking was
         never cleared since the last one. Measured on 7 products: 7/7.

2.  Σ delta  ==  stock_quantity
       — FALSE. Do not use it. Measured on the same 7 products: 0/7.
         Product 1: Σ delta = -2, stock = 3.  Product 12: Σ delta = 0, stock = 5.
         The gap is the opening balance: turning tracking on writes no movement.

3.  balance_after(n) - balance_after(n-1)  ==  delta(n)
       — holds within an unbroken stretch of tracking. Clearing tracking and
         re-counting breaks it, and the ledger is then internally inconsistent
         on purpose (§10, row 10): measured chain `order/-3/bal7` → `cancel/+3/bal5`.

4.  per (order_id, product_id, reason, round): at most one row
       — enforced by `uq_stock_movements_order_reason_round`, partial on
         `order_id IS NOT NULL`. A manual movement has NO idempotency key at all;
         it is idempotent by value instead (setting the same number twice writes
         nothing) and serialised by the `FOR UPDATE` on the product row.

5.  one order line + one gift line of the same product  ==  ONE movement
       — `mergeStockMoves`. Measured: cart 2 + 3 of one product → `order/-5`.

<!-- /gate -->

How to reconcile a discrepancy. Read balance_after of the newest movement and compare it with stock_quantity (identity 1). If they differ, someone wrote the number outside SetStock/ApplyStockMoves — the ledger is the evidence, the number is the claim. Do not sum deltas.

8. Integers and boundaries

Stock is whole units end to end: INTEGER in the database, int32 on the wire, int in Go. There is no rounding anywhere in this area and no place a fraction could appear — the only arithmetic is after = stock + delta.

The boundaries that do exist:

  • stock_quantity >= 0 is a database CHECK; the engine refuses a negative result with out_of_stock before the constraint can fire, so a negative stock is unreachable through the API.
  • quantity: 2147483647 is accepted (measured), and the following edit down to 0 writes manual/-2147483647 without overflow — delta is int in Go and INTEGER in the column, and the difference of two non-negative int32 fits.
  • Cart quantity is clamped at 9999 (LEAST(cart.quantity + …, 9999)), which is the only ceiling on how much one line can try to take off the shelf.

9. How stock is wired into promotions

This is the part that surprises people, because a gift is free and yet it costs you inventory.

A gift line is deducted exactly like a paid one. CreateOrderFromCart builds one StockMove per paid line and one per gift line, all with reason order, and passes them together (pg/orders.go:277-305). The gift is materialised as a real order_items row (unit_price: 0, base_unit_price = its value), so it is a real thing that leaves the warehouse.

`mergeStockMoves` then collapses them per product, which is what makes "buy two, get the third free" work: three units leave in one movement, not two movements of two and one.

Stock is a condition of handing the gift out, like publication. In promotions/apply.go:370:

if gc.Stock != nil && *gc.Stock < giftNeed(lines, res.Lines, gc.ProductID, qty) {
    continue // the rule silently does not fire
}

Before this check existed, a short stock on a *free* line aborted the whole order with out_of_stock — over a line the buyer never picked and cannot remove, because it is virtual and not in the cart table.

"Ran out" is measured against what THIS cart needs (giftNeed): the rule's own quantity, plus every unit of the same product the buyer is paying for, plus gifts of the same product from other rules that fired in this cart. A rule that did not fire holds nothing back. The decision is made inside the engine, not in the resolver, precisely because the resolver runs before pricing and does not know which gift rules will fire — it could only sum the appetite of *all* live rules, and a rule that this cart does not satisfy would silently eat the stock of one that does.

Stock == nil (untracked) hands the gift out as always.

The race window is real and still open — but it no longer costs the order. The gift's stock is read by giftProductsQuery (pg/promotions.go:582-591) with a plain SELECT, no `FOR UPDATE`; the lock is taken later, by the write path. Both are inside the order transaction, and the transaction holds pg_advisory_xact_lock(userID, companyID) — which serialises one buyer's own double submit and nothing between different buyers. So another buyer really can take the last unit between the gift check and the deduction.

What happens then (since 2026-08-20): the gift is dropped, not the order. The write path knows which units of a move are gift units (§4.5, step 5), so a shortage on them deducts the paid part, writes no movement for the gift part and returns the product id; checkout then deletes that gift order_items row (pg/orders.go). The buyer gets the outcome the resolver would have produced had it read stock a millisecond later — exactly so for a single gift rule, and see the per-rule exception below: the order goes through, there is no gift, and no total changes — a gift costs 0 and is in none of items_total, discount_total or promotions[]. Mixed moves are split rather than skipped: "take two, get the third free" on a stock of two deducts the two paid units and withholds the free one. A gift of two against a stock of one is withheld whole — the same all-or-nothing as the resolver. A shortage on a paid line still aborts the entire order.

The equivalence with the resolver holds per _product_, not per _rule_. Two gift rules granting the *same* product merge into one movement (mergeStockMoves), so a shortage there withholds both, while the resolver reading the smaller number would have handed out one and skipped the second (giftNeed counts gifts already granted, rule by rule). The direction of the error is the safe one — the buyer is never given more units than the shelf holds — but they can be given fewer than a resolve at the same number would have given. Not gated; see D10.

A reopen degrades too, and keeps the line. deductReopenedOrderStock builds its movements from the ledger, where an issued gift's units are indistinguishable from paid ones — so the gift units are taken from the order instead (order_items.promotion->>'kind' = 'gift', summed per product) and passed as GiftUnits into the same write path. A gift sold out while the order sat cancelled is therefore not reserved again, and the reopen goes through (pg/orders.go:574-680, owner's decision №6 of 2026-08-20).

The one difference from checkout is what happens to the order_items row. Checkout deletes it: the row was seconds old, nothing stands behind it, and leaving it would promise the buyer a product that was never taken off the shelf. A reopen keeps it: that gift was handed out, deducted and returned by the cancellation — it is a fact of a placed order, and an order's snapshot is never rewritten (§3 of orders.md). What to do at shipping — restock it, substitute it, or explain — is the seller's call, and deleting the row would take the fact away from them. The withheld product ids are logged, not swallowed.

The ledger stays balanced, but by two different mechanisms, depending on whether the gift is a *separate* product or *the same* product that was bought.

*Separate product.* The withheld unit writes no `reorder` row at all, and the round of the next cancellation counts reorder rows (§4.4) — so that cancellation does not advance either, hits the unique index and puts nothing back. Two full cancel↔reopen cycles after a withheld reopen leave the gift's ledger at exactly order/-1, cancel/+1 (TestB54Reopen_WithheldGiftKeepsLedgerBalanced).

*Same product as a paid line* ("buy two, the third is free" — a first-class shape, giftNeed in services/promotions/apply.go:395 counts the paid lines of the same product, and checkout covers it with TestB5Gift_GiftOfTheBoughtProductCountsBothLines). Here mergeStockMoves collapses paid and free into one movement, so a withheld gift does write a reorder row — just a *smaller* one (-2 where checkout wrote -3). Nothing about the round saves this case. Two things carry it instead:

  • restoreOrderStock returns the last deduction per product (order or reorder, whichever is later), not the checkout row. Reading the checkout row handed the shelf +3 where the order held 2 — a unit minted from nothing, on every such cycle (TestB54Reopen_MixedGiftOfSameProductLedger; ledger order/-3, cancel/+3, reorder/-2, cancel/+2).
  • deductReopenedOrderStock derives GiftUnits as *movement minus units bought*, capped by the gift quantity — it does not read the gift quantity straight out of order_items. From the second cycle on the movement no longer contains the gift unit at all, and the raw mark would relabel a paid unit as free: the shortage would silently drop it and reopen the order holding one unit against two sold (TestB54Reopen_SecondRoundKeepsPaidUnitsPaid).

A shortage on a paid line still fails the whole reopen — there is nothing to ship (TestStockRound_SoldOutReopenChangesNothing).

⚠️ **A gift product that was *deleted*, rather than sold out, still fails the reopen** — and with a different error. The write-off filters on deleted_after IS NULL for any deduction, finds no row, and returns domain.ErrNotFound *before* the gift branch is ever reached. Measured on reopen_fix 2026-08-20 by a temporary probe: gift product soft-deleted while the order sat cancelled → UpdateOrderStatus: Not found, order still status 4, paid stock unmoved at 5. This is the reopen leg of D10 (a); it is not gated, and the probe was not committed.

A lock at gift-resolve was rejected, not postponed. Resolve and write-off take product rows at *different points* of the same transaction. Locking at resolve would let two opposite orders — "gift G plus paid P" against "gift P plus paid G" — take two rows in opposite order and deadlock. Degradation adds no locks at all and gives the buyer the same result.

Window size: median 0.12–0.15 ms (one paid line, stock untracked) to 1.67–1.71 ms (ten lines, stock tracked); upper bound median 0.36–2.04 ms across the four configurations, its p99 up to 7.3 ms per run. It grows with both the line count and stock tracking (TestB5GiftRace_WindowMeasure, 8 runs × 200 orders per cell). The number is now a *regression guard on the width of the window*, not a measure of exposure: the consequence is closed, the interval is not. Behaviour inside it is proven deterministically by TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder, TestB5GiftRace_MixedMoveDeductsThePaidPartOnly, TestB5GiftRace_PartialGiftStockInTheWindowWithholdsAll and TestB5GiftRace_PaidLineShortageStillKillsTheOrder.

Everything else in the promo ladder is stock-blind. Prices, thresholds, budgets and coupon issuance never read stock_quantity; only the gift step does.

10. Silently ignored

Cases where the platform answers 200 and does nothing, or does something other than what the field name promises. Every row was executed, not recalled; the probe name is given where one exists.

What you sendWhat happensWhy / how measured
stock_quantity on PUT /products/{id}ignored — the number is unchanged, no ledger rowDeliberate (edit_product.go:512-522): the product form sends the value it was opened with, and everything sold in between would be overwritten silently and past the ledger. TestTZM5_EditProductIgnoresQuantity: stock 7, sent 999, stock stayed 7, ledger empty, stock_low_threshold in the same request applied.
low_threshold omitted on PUT /products/{id}/stockthe previously configured threshold is erased (set to NULL)Not a decision — a missing flag. quantity has setQty to separate "not sent" from "clear it"; low_threshold has no counterpart, and both branches of the UPDATE write it unconditionally (pg/stock.go:237-248). TestTZM5_OmittedThresholdIsErased: threshold 5 → send {"quantity":19} → threshold NULL. An empty body {} erases it too. Defect D1 (§14).
quantity on a product whose stock is not trackedthe number is set, no ledger rowThe condition requires current != nil (pg/stock.go:249-259). Turning tracking on is a change of mode; a row saying "12 arrived" would be a claim about the warehouse nobody made. This is the reason identity 2 fails. TestTZM5_TurnTrackingOnWritesNoMovement, TestTZM5_ManualOnUntrackedWritesNothing.
The same number again on PUT /products/{id}/stockno ledger row, updated_at still bumpedThe row is written only on a real change. Idempotency by value, since a manual movement has no unique key (identity 4). TestTZM5_ManualEditWritesDelta: three calls (10, 7, 7) → exactly one row manual/-3/bal7.
quantity: -1 (any negative)tracking is cleared, no ledger row, in_stock comes back `true`The sign is the only way to express "clear" through one optional integer. TestTZM5_NegativeQuantityUntracks. The in_stock: true is correct and surprises people: an untracked product never runs out.
low_threshold: -1the threshold is clearedSame sign rule. Measured on both endpoints: PUT /products/{id}/stock and PUT /products/{id}.
A second click on "Cancel"no second return of units"cancelled → cancelled" is a legal transition and reaches the stock layer in full; the unique index on (order, product, reason, round) drops the duplicate. TestStockRound_ThreeCyclesReturnStock clicks every transition of three cycles twice and asserts the stock after each.
limit above the ceiling on /products/{id}/stock/history or /products/low-stockfalls back to the default, not to the ceiling, and answers 200`if limit <= 0 \\limit > 200 { limit = 50 } (history) and > 500 { limit = 100 } (low-stock). TestTZM5_LimitFallsBackToDefault over 300 movements: limit=200 → 200 rows, limit=201 → 50, limit=100000 → 50, limit=0 → 50, limit=-7 → 50`. Asking for more than the ceiling gets you fewer rows than asking for the ceiling.
Adding more units than exist to the cartaccepted; only checkout refusesThe cart checks stock_quantity IS NULL OR stock_quantity > 0 — presence, not amount (pg/cart.go:23-32), and reserves nothing. TestTZM5_AddToCartOutOfStock: stock 1, added 5, cart holds 5, ConfirmCartout of stock, stock unchanged at 1. Stock 0 is refused at add time with FailedPrecondition out of stock.
Clearing tracking while an order is open, then cancelling itthe units are not returned and are lostrestoreOrderStock builds returns from the ledger, ApplyStockMoves skips untracked products. TestTZM5_UntrackedProductCancelReturnsNothing: stock 5 → order 2 → clear tracking → cancel → stock NULL, ledger keeps only order/-2. Nothing errors.
Clearing tracking, recounting, then cancelling the open orderthe order's units are added to the new number — phantom stockTestTZM5_UntrackMidOrderBreaksTheChain: stock 10 → order of 3 (stock 7) → clear → recount to 2 → cancel → stock 5. The ledger reads order/-3/bal7 then cancel/+3/bal5, internally inconsistent by construction. Three units the seller had already excluded from the count are back on the storefront. Defect D2 (§14).
A kind: "gift" rule whose gift product ran outthe gift is withheld silently; the cart, the order and what the buyer pays are all priced without itStock is a condition of handing it out, like publication (§9). TestB5Gift_OutOfStockGiftIsWithheldAndOrderPasses, TestB5Gift_PartialStockWithholdsTheWholeGift, TestB5Gift_UnfiredSecondRuleDoesNotEatTheStock — all green on tz_m5 2026-08-20.
A gift product that runs out between the check and the deductionstill withheld silently — the gift row is deleted from the order, the paid lines are deducted, the order succeeds and no total movesThe write path degrades on gift units instead of failing (§4.5 step 5, §9). Nothing tells the buyer the gift was withdrawn — the same silence as a gift that was out of stock from the start. TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder, TestB5Gift_WithheldGiftInTheWindowMovesNoMoney — green on race_close 2026-08-20.
Reopening a cancelled order whose gift sold out meanwhilethe reopen succeeds; the gift is not reserved again, the paid lines are, the gift row stays in the order and no total movesOwner's decision №6 of 2026-08-20 (§9). The gift line of a placed order is a historical fact, so unlike checkout it is not deleted — the seller decides at shipping. Nothing tells anyone the gift is now unbacked except the seller's own stock screen; the withheld product ids go to the log. TestB54Reopen_SoldOutGiftDoesNotBlockReopen, TestB54Reopen_WithheldGiftKeepsLedgerBalanced — green on reopen_fix 2026-08-20.
Two cart lines of one product (two configurations, or paid + gift)one ledger row with the summed deltamergeStockMoves. Expecting one row per order line is the natural reading and it is wrong. TestTZM5_MergeStockMovesOneRowPerProduct: lines 2 + 3 → order/-5, count(*) = 1.
Expecting reason: "return" or "import" from the ledgernever appearsNothing writes them; see §4.3 and §12. The served OpenAPI still advertises both, and omits reorder.
Expecting created_by on a StockMovementnot in the message at allWritten to the table, never mapped (catalog.proto:1741-1754).
Editing stock from a dev-contour sessionwrites to the live productThe handlers have no contour branch (`grep -c 'contourEligible\devOverlay\dc.dev' internal/api/catalog/stock.go0), and migration 0089` deliberately took stock out of the overlay: it is an operational value of the live contour, like views and rating.
Reading stock_quantity anonymouslythe exact number comes back, and so does stock_low_thresholdTestTZM5_ExactAnonAndEdges, no session: GET /products/{id}stock_quantity: 3, stock_low_threshold: 2. GET /products returns stock_quantity too. Whether that is intended is a question for the owner — §14, D3.

Loud failures, so you will notice: id: 0400 invalid SetProductStockRequest.Id; an unknown, deleted or foreign product → 404 not_found on write; a buyer with no shop reading the ledger → 403 forbidden; a checkout short of stock → 412 out_of_stock; a reopen short of stock on a paid line → the same, and the status does not move (TestStockRound_SoldOutReopenChangesNothing). A reopen short of stock on a *gift* line is not a failure at all — see §9 and the row above.

11. Limits

<!-- gate:limits -->

Not generated — no machine reference exists for this area yet (§13). Every value below was read from the source on 2026-08-20 and, where a probe column says so, executed.

LimitValueWhere it livesMeasured
stock/history default limit50pg/stock.go:307yes — limit 0 / −7 / 201 / 100000 all → 50 rows
stock/history max limit200pg/stock.go:307yes — limit=200 → 200 rows
low-stock default limit100pg/stock.go:277from source
low-stock max limit500pg/stock.go:277from source
stock_quantity rangeNULL or >= 0chk_products_stock_quantity (0084)yes — 2147483647 accepted
stock_low_threshold rangeNULL or >= 0chk_products_stock_threshold (0084)from source
delta<> 0CHECK (0084)from source; zero-deltas are skipped before the insert
round>= 0chk_stock_movements_round (0116)from source
reasonone of order, cancel, return, manual, import, reorderstock_movements_reason_check (0092)yes — a direct insert of 'wat' → SQLSTATE 23514
commentTEXT, unbounded; '' stored as NULLNULLIF in ApplyStockMovesfrom source
cart line quantity9999LEAST(…, 9999) in AddToCartfrom source
stock/history pagingnonelimit only, no cursor, no offsetpg/stock.go:306-320from source
low-stock pagingnonepg/stock.go:276-300from source

<!-- /gate -->

12. Register of negations (§3.9 of the governing spec)

Every negative statement in this document, and how it is kept honest. Nothing here is guarded by a gate today — the whole column is "manual sweep after each wave", and that is itself the point of §14, D4.

Statement§How it is checked
A PAT cannot call the three stock endpoints2`grep 'SetProductStock\GetLowStock\GetProductStockHistory' internal/core/services/guard.go` → 0 hits. Turns false the day a line is added there.
Σ delta never equals the stock3, 7TestTZM5_TurnTrackingOnWritesNoMovement asserts the ledger is empty after tracking is switched on. Turns false when an opening movement is introduced — and the test goes red.
Nothing writes reason: "return"4.3, 10`grep -rn 'StockReasonReturn' --include='*.go' internal/ \grep -v _test` → declaration only. No test. Sweep item.
Nothing writes reason: "import"4.3, 10Same grep, same result. No test. Sweep item.
created_by is not exposed4.2, 10grep 'created_by' api/catalog/catalog.proto in the StockMovement block → absent. Sweep item.
The stock handlers have no dev-contour branch4.1, 10`grep -c 'contourEligible\devOverlay\dc.dev' internal/api/catalog/stock.go` → 0. Sweep item.
The gift stock read takes no lock9giftProductsQuery SQL contains no FOR UPDATE (pg/promotions.go:588-591). Deliberate: locking there would deadlock two opposite orders (§9).
A withheld gift leaves no ledger row9The write path continues before the INSERT INTO stock_movements when the whole move was gift units. Gated at checkout by TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder (reads the order's movements, requires exactly one, on the paid product) and at reopen by TestB54Reopen_WithheldGiftKeepsLedgerBalanced (two further cancel↔reopen cycles leave the gift's ledger at order/-1, cancel/+1). It matters for cancellation — a return is built from the ledger (§4.4), so a phantom row would put back on the shelf a unit the order never took.
A reopen never deletes an order_items row9, 13grep -n 'DELETE FROM order_items' internal/adapters/repositories/pg/orders.go → one hit, inside CreateOrderFromCart. Gated: TestB54Reopen_SoldOutGiftDoesNotBlockReopen re-reads the gift row after a withheld reopen. Turns false the day the reopen path grows a delete — which would rewrite a placed order's snapshot.
Nothing but the gift step reads stock in the promo engine9`grep -n 'Stock' internal/core/services/promotions/*.go \grep -v _test → 4 hits, all on GiftCandidate.Stock and its use at apply.go:370`. Sweep item.
There is no paging on either read endpoint11Source read. Sweep item.
No machine reference / no gate exists for this areaheader, 4.3, 11True while internal/api/discovery/ has no stock_reference.go. Sweep item — and closing it deletes this row.
/docs/stock is not servedheadergrep 'mux.Get("/docs' internal/api/discovery/discovery.go. Turns false the day the route is added.

13. Why it is this way

Short answers to the questions that make the next developer "fix" something that is not broken.

Why the number lives on the product and not in the ledger. Every read path in the shop — listing, card, cart, gift eligibility — needs "how many are there" under a page load. Deriving it from a ledger means a SUM per product per page. The ledger exists to *explain* the number, not to hold it. The price of that choice is identity 2, and it is stated out loud rather than hidden.

Why the movement is inserted before the number is updated. The unique index is the only thing that makes a repeat safe, and it lives in the database — the one place that survives a restart and a second pod. Deciding "is this a repeat" in process memory would work until there are two of them.

Why the round counts the opposite reason. See §4.4. This is the single least obvious line in the area and the one a "simplification" would break first: with its own counter, a double click on "Cancel" returns the units twice.

Why a cancellation is built from the ledger, not from the order lines. If part of the order was untracked, those products have no ledger rows and there is nothing to return. Reading the order lines instead would invent stock for services and digital goods.

Why a reopen reads only the newest `cancel` row per product (DISTINCT ON). An order now has as many cancellations as it has cycles. Without it, mergeStockMoves would sum them and one reopen would deduct everything the order ever returned across its whole history.

Why a withheld gift is deleted at checkout and kept at a reopen. At checkout the row is seconds old and nothing stands behind it — leaving it would promise the buyer a product that never left the shelf. At a reopen the same row records something that actually happened: the gift was handed out, deducted and returned by the cancellation. Deleting it would rewrite the snapshot of a placed order, which nothing in this platform is allowed to do (§3 of orders.md), and would hide from the seller the very fact they need in order to decide what to ship.

Why a return does not require a live product. An order holding a deleted product must remain cancellable; a strict check would fail the whole status transition and leave the order permanently stuck.

Why a gift is deducted like a paid line. It really leaves the warehouse. The alternative — handing out gifts off the books — makes the number wrong for the one product the seller is most likely to run out of.

Why gift availability is measured against this cart's need and not summed across rules. A rule that does not fire would otherwise reserve stock away from one that does. The check therefore lives inside the engine, after it is known which rules fired.

14. Technical debt, named out loud

A debt named in the document is a plan; unnamed, it is a surprise for whoever comes next. Ordered by cost of the error, not by ease of the fix.

  • D1 — `low_threshold` is erased when omitted. Silent data loss on the most ordinary call, {"quantity": N}. SetStock needs a setThreshold flag symmetric with setQty. Measured: TestTZM5_OmittedThresholdIsErased.
  • D2 — clearing tracking mid-order manufactures stock. Clear → recount → cancel adds the order's units to the seller's new count, and the ledger becomes internally inconsistent (bal7 followed by +3bal5). Either refuse to clear tracking while the product is inside a non-terminal order, or write a compensating movement. Measured: TestTZM5_UntrackMidOrderBreaksTheChain.
  • D3 — exact stock is public. stock_quantity and stock_low_threshold come back to an anonymous reader on GET /products/{id} and GET /products. A competitor can watch a shop's inventory and infer its sales. in_stock alone would serve the storefront. Owner's decision needed — this may be intentional ("3 left!" badges). Measured: TestTZM5_ExactAnonAndEdges.
  • D4 — the area has no gate and no machine reference. promotions.md is kept honest by BuildPromoReference() and byte-for-byte comparison of the embedded copy. Nothing of the sort exists for stock: the reason list, the limits and every negation in §12 are hand-written and can go stale in silence. This is the precondition for Status: current.
  • D5 — the served contract lies about `reason`. The OpenAPI description of StockMovement.reason reads order | cancel | return | manual | import: it omits reorder, which is the reason a reopened order actually writes, and advertises return and import, which nothing writes. Verified in cmd/core/core-api.swagger.json and internal/pb/api/core-api.swagger.json. The fix is in api/catalog/catalog.proto:1748 and needs make gen — out of scope for this run.
  • D6 — the proto comment on `EditProductRequest.stock_quantity` describes behaviour the handler does not implement. It says a negative value clears tracking and a non-negative one sets the stock; the handler ignores the field entirely (edit_product.go:512-522). Either drop the field or drop the comment.
  • D7 — `mergeStockMoves` merges on `ProductID` only and keeps the first move's Reason, Comment and Author. No current caller mixes reasons in one batch, so nothing is wrong today; a future one would have its rows collapsed silently.
  • D8 — the seller-facing write path has no test coverage at all. grep -rln 'SetProductStock\|SetStock\|GetLowStock\|StockHistory' --include='*_test.go' over the whole tree returned nothing before this document. Everything in §10 that touches those three endpoints was measured by probes written for this run and executed under -overlay, i.e. the probes are not in the repository. They should be committed, or the rows they support become claims again.
  • D9 — the ledger cannot answer "who". created_by is stored and never returned; round is stored and never returned. The endpoint is called "where did five units go" and cannot say who took them.
  • D10 — the gift race window is measured; its consequence is closed, the interval is not. Since 2026-08-20 a shortage discovered at write-off drops the gift instead of the order (§9), so the window costs the buyer nothing. The interval itself stays open by decision: a FOR UPDATE at resolve time would reverse the lock order between two opposite orders and deadlock. The numbers in §9 remain as a regression guard on the window's width. Three things are still open, none of them gated: (a) if the gift product is *deleted* (not sold out) inside that window, the write-off's deleted_after IS NULL filter finds no row and the order still dies with ErrNotFound — the same "an order lost over a free line", reachable only by a seller deleting a product mid-checkout. The same hole exists on a reopen, where it is much easier to hit, because the order can sit cancelled for weeks: measured 2026-08-20 on reopen_fix — gift product soft-deleted while cancelled → UpdateOrderStatus: Not found, status stays 4, paid stock unmoved. The liveness filter runs before the gift branch, so decision №6 does not cover it. Ungated, and the probe was not committed; ~~(b) reopening does not degrade~~ — closed 2026-08-20 by owner's decision №6: the reopen takes the gift units from order_items and degrades the same way, keeping the line as a historical fact (§9), gated by TestB54Reopen_SoldOutGiftDoesNotBlockReopen and TestB54Reopen_WithheldGiftKeepsLedgerBalanced; (c) two gift rules over one product merge into one movement, so a shortage withholds both where a resolve at the same number would have handed out one (§9).
  • D11 — `reason` `return` and `import` are reserved and dead. Partial refunds (MarkOrderRefunded without a cancellation) do not touch stock at all; the intersection matrix already lists that as a hole awaiting an owner's decision. Catalogue import does not set stock either.
  • D12 — the low-stock rule is implemented twice. domain.StockLow in Go and the WHERE of LowStock in SQL. They agree today only because the CHECK forbids a negative stock (== 0 vs <= 0). domain.StockLow and domain.StockTracked have no non-test callers, and domain.StockUpdate.Quantity is never populated by anyone.
  • D13 — no paging on either read endpoint. A product with a long history is readable only 200 rows deep, with no cursor.

15. Recipes

$API is the shop's API base. $SESSION is a cabinet session token, not a PAT — see §2, a PAT is refused with PAT_METHOD_NOT_ALLOWED.

15.1 Start tracking a product and set the number

curl -X PUT "$API/products/$PRODUCT_ID/stock" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"quantity": 12, "low_threshold": 3}'

Verify: the response carries {"quantity":12,"low_threshold":3,"in_stock":true}, re-read from the product rather than echoed. Then GET /products/$PRODUCT_ID/stock/history — it is empty, and that is correct: switching tracking on is not a movement (§10). The ledger starts at your first real change.

⚠️ Always send low_threshold together with quantity. Omitting it erases the threshold you set earlier (§10, row 2).

15.2 Correct the number after a recount

curl -X PUT "$API/products/$PRODUCT_ID/stock" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"quantity": 9, "low_threshold": 3}'

Verify: GET /products/$PRODUCT_ID/stock/history now has one row — {"delta":-3,"balance_after":9,"reason":"manual","comment":"ручная правка остатка"}. Sending 9 again adds nothing. Seeing 200 proves only that the request was accepted.

15.3 Stop tracking (service, digital good, made to order)

curl -X PUT "$API/products/$PRODUCT_ID/stock" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"quantity": -1, "low_threshold": -1}'

Verify: GET /products/$PRODUCT_ID returns no stock_quantity field (absent, not 0) and in_stock: true. The product now never runs out. No ledger row is written. Sending 0 instead of -1 means the opposite — "tracked, sold out" — and takes the product off the storefront.

15.4 Set only the "running low" threshold

curl -X PUT "$API/products/$PRODUCT_ID" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"item": {"stock_low_threshold": 5}}'

Deliberately not the stock endpoint: this one touches nothing else, whereas PUT …/stock without quantity still rewrites the threshold and bumps updated_at. stock_quantity in this body would be accepted and ignored.

Verify: GET /products/$PRODUCT_ID shows stock_low_threshold: 5 and an unchanged stock_quantity; the ledger has no new row.

15.5 "Where did five units go"

curl "$API/products/$PRODUCT_ID/stock/history?limit=50" \
  -H "Authorization: Bearer $SESSION"

Read it as a story, newest first:

[
  {"id":"38","delta":2,"balance_after":8,"reason":"cancel","order_id":18,"comment":"отмена заказа"},
  {"id":"37","delta":-2,"balance_after":6,"reason":"order","order_id":18},
  {"id":"36","delta":-2,"balance_after":8,"reason":"manual","comment":"ручная правка остатка"}
]

id arrives as a string (int64). order_id is 0 when the movement was manual. A reorder row means the seller put a cancelled order back to work.

Verify the number itself: balance_after of the top row must equal stock_quantity on the product (identity 1). Do not sum delta — see §7.

Do not ask for more than 200; 201 gets you 50 (§10).

15.6 What is running out

curl "$API/products/low-stock?limit=100" -H "Authorization: Bearer $SESSION"

Verify: a product at 0 with no threshold is listed; a product at 2 with no threshold is not; a product at 2 with low_threshold: 2 is (TestTZM5_LowStockRules). Untracked products never appear. Drafts do.

15.7 Understand a checkout that failed with out_of_stock

The order was refused whole — nothing was deducted and no ledger row was written (TestTZM5_AddToCartOutOfStock re-reads the stock after the failure: unchanged). Three causes, in order of likelihood:

  1. The cart holds more units than the product has. The cart only checks that the product is not at zero; it never checks the amount and reserves nothing (§10).
  2. The same product appears on two cart lines and the *sum* exceeds the stock — the movements are merged per product (§4.5).
  3. A gift of a product the buyer is also buying: the gift's need includes the paid units (§9). The gift itself would have been withheld silently rather than fail the order, so this shows up as an ordinary shortage of the paid line.

At checkout a gift is not the cause. Out of stock at resolve time it is not handed out; out of stock at write-off time it is dropped from the order (§9). If the refusal names a product the buyer never picked, it is a paid line of that product, not the gift.

A reopen answers the same way, and for the same reason. UpdateOrderStatus back out of cancelled replays the ledger, but it reads the gift units off the order itself, so a gift sold out during the cancellation is simply not reserved again and the reopen succeeds (§9). If a reopen answers out_of_stock, the product it names is a paid line. A free line can still stop a reopen in one way, and it looks different: a gift product that was *deleted* answers not_found, not out_of_stock (§9, D10 (a)). The seller's own screen is the only place an unbacked gift shows up: the line is still in the order, the shelf is not.

16. How this was verified

  • 2026-08-20, database tz_m5 on localhost:5433, schema built by goose from sql/migrations at branch feature/promo-platform (migrations through 0116), binary built from that branch.
  • Existing coverage, re-run and green (4/4): TestStockRound_ThreeCyclesReturnStock, TestStockRound_LedgerHasOnePairPerCycle, TestStockRound_SoldOutReopenChangesNothing, TestStockRound_ParallelCancelsReturnStockOnce (internal/api/catalog/stock_second_round_integration_test.go).
  • The reopen/gift degradation of §9 (owner's decision №6), added 2026-08-20 on database `reopen_fix`: TestB54Reopen_SoldOutGiftDoesNotBlockReopen (the reopen passes, the paid line is deducted, the gift is not, the gift row and both totals survive) and TestB54Reopen_WithheldGiftKeepsLedgerBalanced (two further cancel↔reopen cycles: paid ledger order/-2, cancel/+2, reorder/-2, cancel/+2, reorder/-2; gift ledger order/-1, cancel/+1 and nothing more). Both were red before the change with UpdateOrderStatus: out of stock (internal/api/catalog/promo_reopen_integration_test.go).
  • The same-product gift shape, added 2026-08-20 on database `reopen_skeptic11` after review found decision №6 leaked stock in it: TestB54Reopen_MixedGiftOfSameProductLedger — red on the first cut of №6 with остаток 3, ждали 2 … журнал: [order/-3 cancel/+3 reorder/-2 cancel/+3], i.e. the shelf gained a unit that no order ever held; and TestB54Reopen_SecondRoundKeepsPaidUnitsPaid — red once restoreOrderStock alone was fixed, with заказ держит 1 единиц из двух оплаченных … журнал: [order/-3 cancel/+3 reorder/-2 cancel/+2 reorder/-1]. Each gate covers one half of the fix; neither half is redundant.
  • Gift-side coverage, re-run and green (9/9): TestB5Gift_*, including OutOfStockGiftIsWithheldAndOrderPasses, PartialStockWithholdsTheWholeGift, UnfiredSecondRuleDoesNotEatTheStock, GiftOfTheBoughtProductCountsBothLines, UntrackedStockStillHandsTheGiftOut.
  • Written for this document and executed (9 scenarios, all green): TestTZM5_TurnTrackingOnWritesNoMovement, TestTZM5_ManualEditWritesDelta, TestTZM5_OmittedThresholdIsErased, TestTZM5_NegativeQuantityUntracks, TestTZM5_ManualOnUntrackedWritesNothing, TestTZM5_HistoryIsExposedAndTenantScoped, TestTZM5_LowStockRules, TestTZM5_EditProductIgnoresQuantity, TestTZM5_AddToCartOutOfStock; and a second batch: TestTZM5_LimitFallsBackToDefault, TestTZM5_UntrackMidOrderBreaksTheChain, TestTZM5_UntrackedProductCancelReturnsNothing, TestTZM5_AnonymousSeesExactStock, TestTZM5_MergeStockMovesOneRowPerProduct, TestTZM5_ReasonsActuallyWritten, TestTZM5_ExactAnonAndEdges. ⚠️ These probes ran through `go test -overlay` and are NOT in the repository — this run was scoped to a single file. Until they are committed, §10 rests on a measurement that cannot be repeated by anyone else. That is D8.
  • Identities 1 and 2 were measured in SQL, not reasoned about, over the data the whole suite left behind: 7 products with movements, balance_after of the newest row equalled stock_quantity in 7/7, and Σ delta equalled it in 0/7.
  • The reason census (cancel 11, manual 304, order 12, reorder 8, return 0, import 0) is a GROUP BY over the same database, plus direct inserts proving the CHECK accepts return/import and refuses 'wat'.
  • The PAT statement of §2 comes from reading internal/core/services/guard.go: getRequiredRoles matches "/vizenpro.api.catalog.v1.Catalog/*" → {RoleUser}, so the public-method branch is not taken, and DefaultMethodScopes has no entry for any of the three methods → PermissionDenied PAT_METHOD_NOT_ALLOWED. It was not exercised over live HTTP; that is the weakest link in this document and the first thing to confirm on a stand.
  • The race window number in §9 was filled 2026-08-20 from the measurement test named there; before that the placeholder was deliberate (§3.9: no value without a measurement).
  • The degradation of §9 was measured the same day, database race_close, four deterministic race scenarios plus the money one, each red before the change and green after: TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder, TestB5GiftRace_MixedMoveDeductsThePaidPartOnly, TestB5GiftRace_PartialGiftStockInTheWindowWithholdsAll, TestB5Gift_WithheldGiftInTheWindowMovesNoMoney, and TestB5GiftRace_PaidLineShortageStillKillsTheOrder, which was green before and after by design — it is the guard that the degradation did not spread to paid lines. Its teeth were checked by mutation: widening the shortage branch to any product turns it red. Neighbours re-run green in the same database: -run 'TestB5|TestB49|TestB54|TestStock'.

Исходник: https://api.vizen.shop/docs/stock

Работает в демо-режиме