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

> **Резюме по-русски.** Склад в 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 path | Cost |
|---|---|---|
| Manage stock **with a personal API token** | **not possible today** | `SetProductStock`, `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 units | `PUT /products/{id}/stock` | The only path that writes a ledger row. Takes the product row under `FOR UPDATE`. |
| Set only the **threshold** | either `PUT /products/{id}/stock` with `low_threshold` alone, or `PUT /products/{id}` with `stock_low_threshold` | Same 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 form** | you cannot — `stock_quantity` on `PUT /products/{id}` is accepted and ignored | Deliberate: 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 it | Nothing 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 stock | not available through the API | `stock_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 ledger | **do 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" time | not possible | The 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`

| Column | Meaning | Written by |
|---|---|---|
| `stock_quantity` | `INTEGER`, `NULL` = not tracked, `>= 0` enforced by `chk_products_stock_quantity` | `PUT /products/{id}/stock`; every order transaction |
| `stock_low_threshold` | `INTEGER`, `NULL` = warn only at zero, `>= 0` enforced by `chk_products_stock_threshold` | `PUT /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`)

| Column | Meaning |
|---|---|
| `id` | `BIGINT` identity. **`int64` → a JSON string on the wire.** |
| `company_id` | tenant, `NOT NULL` — every table knows its shop |
| `product_id` | `ON DELETE CASCADE` |
| `order_id` | the order that caused it; `NULL` for a manual edit |
| `delta` | signed, `CHECK (delta <> 0)`; minus = left the shelf |
| `balance_after` | the stock **after** this movement |
| `reason` | see §4.3 |
| `round` | the "cancel → back to work" cycle, `>= 0`, added by migration `0116` |
| `comment` | `TEXT`; an empty string is stored as `NULL` (`NULLIF`) |
| `created_at` | transaction timestamp — rows of one order share it |
| `created_by` | the 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 -->

| `reason` | Written when | Written by |
|---|---|---|
| `order` | checkout takes the units off | `CreateOrderFromCart` |
| `cancel` | the order moved to *cancelled*, units come back | `restoreOrderStock` |
| `reorder` | a cancelled order was put back to work, units go out again | `deductReopenedOrderStock` |
| `manual` | the seller recounted the boxes | `SetStock` |
| `return` | **nothing writes it** — the constant `domain.StockReasonReturn` has no non-test caller | — |
| `import` | **nothing 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 < 0` → `domain.ErrOutOfStock`
   → `FailedPrecondition 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`

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

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

| `quantity` | Effect |
|---|---|
| omitted | the number is **not touched** (`setQty == false`) |
| `>= 0` | the number is set; `0` is a legitimate "sold out" |
| `< 0` | **tracking 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:

```go
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`:

```go
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 send | What happens | Why / how measured |
|---|---|---|
| `stock_quantity` on `PUT /products/{id}` | **ignored** — the number is unchanged, no ledger row | Deliberate (`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}/stock` | **the 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 tracked** | the number is set, **no ledger row** | The 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}/stock` | **no ledger row**, `updated_at` still bumped | The 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: -1` | the threshold is **cleared** | Same 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-stock` | falls 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 cart | accepted; only checkout refuses | The 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, `ConfirmCart` → `out 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 it | the units are **not returned** and are lost | `restoreOrderStock` 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 order | the order's units are added to the **new** number — phantom stock | `TestTZM5_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 out | the gift is **withheld silently**; the cart, the order and what the buyer pays are all priced without it | Stock 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 deduction | still withheld silently — the gift row is deleted from the order, the paid lines are deducted, the order succeeds and no total moves | The 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 meanwhile | the reopen **succeeds**; the gift is not reserved again, the paid lines are, the gift row **stays** in the order and no total moves | Owner'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 delta | `mergeStockMoves`. 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 ledger | never appears | Nothing writes them; see §4.3 and §12. The served OpenAPI still advertises both, and omits `reorder`. |
| Expecting `created_by` on a `StockMovement` | not in the message at all | Written to the table, never mapped (`catalog.proto:1741-1754`). |
| Editing stock from a **dev-contour** session | writes to the **live** product | The handlers have no contour branch (`grep -c 'contourEligible\|devOverlay\|dc.dev' internal/api/catalog/stock.go` → `0`), 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` **anonymously** | the exact number comes back, and so does `stock_low_threshold` | `TestTZM5_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: 0` → `400 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.

| Limit | Value | Where it lives | Measured |
|---|---|---|---|
| `stock/history` default `limit` | 50 | `pg/stock.go:307` | yes — `limit` 0 / −7 / 201 / 100000 all → 50 rows |
| `stock/history` max `limit` | 200 | `pg/stock.go:307` | yes — `limit=200` → 200 rows |
| `low-stock` default `limit` | 100 | `pg/stock.go:277` | from source |
| `low-stock` max `limit` | 500 | `pg/stock.go:277` | from source |
| `stock_quantity` range | `NULL` or `>= 0` | `chk_products_stock_quantity` (0084) | yes — `2147483647` accepted |
| `stock_low_threshold` range | `NULL` or `>= 0` | `chk_products_stock_threshold` (0084) | from source |
| `delta` | `<> 0` | `CHECK` (0084) | from source; zero-deltas are skipped before the insert |
| `round` | `>= 0` | `chk_stock_movements_round` (0116) | from source |
| `reason` | one of `order`, `cancel`, `return`, `manual`, `import`, `reorder` | `stock_movements_reason_check` (0092) | yes — a direct insert of `'wat'` → SQLSTATE 23514 |
| `comment` | `TEXT`, unbounded; `''` stored as `NULL` | `NULLIF` in `ApplyStockMoves` | from source |
| cart line quantity | 9999 | `LEAST(…, 9999)` in `AddToCart` | from source |
| `stock/history` paging | **none** — `limit` only, no cursor, no offset | `pg/stock.go:306-320` | from source |
| `low-stock` paging | **none** | `pg/stock.go:276-300` | from 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 endpoints | 2 | `grep 'SetProductStock\|GetLowStock\|GetProductStockHistory' internal/core/services/guard.go` → 0 hits. Turns false the day a line is added there. |
| `Σ delta` never equals the stock | 3, 7 | `TestTZM5_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, 10 | Same grep, same result. **No test.** Sweep item. |
| `created_by` is not exposed | 4.2, 10 | `grep 'created_by' api/catalog/catalog.proto` in the `StockMovement` block → absent. Sweep item. |
| The stock handlers have no dev-contour branch | 4.1, 10 | `grep -c 'contourEligible\|devOverlay\|dc.dev' internal/api/catalog/stock.go` → 0. Sweep item. |
| The gift stock read takes no lock | 9 | `giftProductsQuery` 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 row | 9 | The write path `continue`s 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` row | 9, 13 | `grep -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 engine | 9 | `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 endpoint | 11 | Source read. Sweep item. |
| No machine reference / no gate exists for this area | header, 4.3, 11 | True while `internal/api/discovery/` has no `stock_reference.go`. Sweep item — and closing it deletes this row. |
| `/docs/stock` is not served | header | `grep '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 `+3` → `bal5`). 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

```bash
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

```bash
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)

```bash
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

```bash
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"

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

Read it as a story, newest first:

```jsonc
[
  {"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

```bash
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'`.
