Safety nets & runbook
The stock / reordering engine writes movements that commit real money: a drifting ledger produces under-ordered supplier orders, stockouts, or silent over-ordering. The platform therefore stacks three defense layers (audited invariants, pinned tests, production replay-diff) plus a daily health probe. This page is the operational reference: what each layer guarantees, the exact commands to run, and decision trees for the three most frequent incidents.
The three layers at a glance
| Layer | Tool | When to use it |
|---|---|---|
| 1. Data invariants | stock-ledger-audit CLI task (I1…I7) |
Daily probe, CI gate (--strict), healing (--apply) |
| 2. Code regression | pinned unit tests (packages/tests) |
On every ledger-touching commit; every new bug adds a case |
| 3. Behavior drift | replay-supplier-orders.ts (read-only prod diff) |
Before merging any change in the reordering blast radius |
The layers complement each other: layer 1 catches broken data (whatever code broke it), layer 2 catches code that regresses (before it touches data), layer 3 catches behavior changes that neither an invariant nor a unit test spells out — "this supplier order would have 12 more lines on my branch".
Layer 1 — Invariants: stock-ledger-audit
The task codifies the invariants the ledger MUST satisfy. It was born from the May 2026 "Bio Rennes" investigation (phantom stock on untracked products, supplier orders silently under-ordered, the reconcile cron failing mutely for weeks) and has grown with every incident since.
The three modes
# Report (read-only, always exits 0)
cd apps/app && bun run cli --task stock-ledger-audit \
--tenantId <tenantId> --siteId <siteId>
# CI / monitoring gate — non-zero exit on any violation
cd apps/app && bun run cli --task stock-ledger-audit \
--tenantId <tenantId> --siteId <siteId> --strict
# Heal — writes ONLY to rows identified as broken
cd apps/app && bun run cli --task stock-ledger-audit \
--tenantId <tenantId> --siteId <siteId> --apply
The invariants
| ID | Invariant | --apply heals? |
|---|---|---|
| I1 | No product with Product.stock < 0 (the displayed mirror is clamped at 0 — see the ledger) |
yes |
| I2 | No manageStock=false product with stock ≠ 0 (the Bio Rennes case) |
yes |
| I3 | No stale PENDING SUPPLIER_DELIVERY projection — leftovers of cancelled orders, or SENT/CONFIRMED orders with no delivery for ≥ stalePendingDays (default 14 d) |
yes (cancelled + overdue-sent) |
| I4 | No StockAction whose referenceId points at a deleted supplier order |
yes |
| I5 | No tracked product where Product.stock ≠ Σ COMPLETED StockActions — under STOCK_LEDGER_ALLOW_BACKORDER the equality becomes stock == max(0, Σ) |
yes — in prod (backorder ON): resyncs the Product.stock mirror only, no SA (the stockLedger cache is I5b's job); in legacy (backorder OFF): "Backfill" reconciliation SA |
| I5b | Product.stockLedger (the cache column trustedStock actually reads) ≠ Σ COMPLETED — catches cache-only drift that I5 cannot see |
yes (snaps the cache) |
| I6 | No "clamp-signature" fulfillment DECREMENT (quantityChange=0 on a delivery) — the lost consumption that manufactured phantom stock before #2886 |
no (the fix is code, not data) |
| I7 (info-only) | ProductVariation.stock ≠ Σ COMPLETED variation-scoped SAs — drift at the variation level |
no — never (see below) |
Four subtleties that prevent false alarms:
- I3 never touches drafts.
PENDINGprojections belonging toDRAFTorders are the merchant's review queue (the cron stages drafts, the merchant reviews before sending). They are reported as info, never auto-cancelled. - I5 under backorder. Since the
STOCK_LEDGER_ALLOW_BACKORDERcutover (2026-07-09), a negative Σ is legitimate (backorder); I5 compares the clamped mirror, I5b pins the raw cache. - I6 is gated by the cutover. Historical debt (pre-cutover clamps) is reported info-only;
only clamps after
STOCK_LEDGER_BACKORDER_CUTOVERfail--strict— any new clamp is a code regression and must be treated as one. - I7 is pure information — never gated, never healed. Variation stock sits outside the
ledger model: the trust boundary and the supplier-order math read the product only,
I5/I5b deliberately scope to
productVariationId: null, and no cron writes variation rows. I7 therefore just measures how much drift is hiding at that level (number of variations affected + total |drift|). It never fails--strict, and--applynever repairs it: snapping a variation counter onto a Σ that nothing maintains would be the wrong fix.
Audit scope limits — the blind spots, and what now covers them:
- Variation stock is no longer a silent blind spot: I7 reports it since 2026-07 — but info-only (see above). The gating audit remains at product level.
- Inactive products are out of scope: I5/I5b filter on
active: true. - Historically the
stockLedgercache column was not checked at all; I5b closes that gap since 2026-07 — but only for active, tracked products.
If an incident involves a variation or an inactive product, a "green" audit proves nothing: check by hand via the product timeline.
Layer 2 — Pinned regression tests
Every ledger bug found in production becomes a pinned test case: the test replays the incident's real data (anonymized via a snapshot) and fails if the code ever drifts back to the faulty behavior. The four pillars:
File (packages/tests/tests/unit/) |
What it pins |
|---|---|
supplier-order/stock-ledger-trust-boundary.test.ts |
77 cases — the trustedStock trust boundary: real Bio Rennes phantom stock, #2725 negatives, #2728 pessimistic kill switch, 8-week ceiling, boundary table (stock, manageStock) → trustedStock |
stock/stock-write-math.test.ts |
The pure math of recordPhysicalMovement (packages/services/src/stock/rolling-stock-math.ts): ledger sign, clamped mirror max(0, ledger), backorder |
stock/inventory-count-finalize.test.ts |
Physical-count finalize computes its deltas against the signed ledger and skips manageStock=false |
stock/bulk-delete-stock-actions.test.ts |
Bulk deleting actions reverses the ledger and restores stock = max(0, reversed ledger) under backorder |
# The whole stock suite (fast, < 5 s)
cd packages/tests && npm test -- run tests/unit/stock/
# The trust boundary alone
cd packages/tests && npm test -- run tests/unit/supplier-order/stock-ledger-trust-boundary.test.ts
The rule: one bug = one pinned case. When you fix a ledger bug, first capture the affected data slice (PII stripped at snapshot time), commit the fixture next to the test that consumes it, then write the case that fails without the fix:
bun packages/scripts/src/dev/snapshot-tenant-stock.ts \
--tenant-id <tenantId> --site-id <siteId> [--vendor-id <uuid>] \
--days 30 --out packages/tests/fixtures/stock-ledger/<name>.json
Layer 3 — Production replay-diff (read-only)
replay-supplier-orders.ts runs the production paths computeExpectedOrders() and
getCurrentOrders() with the current branch's code against the live database, then prints
per-vendor drift: new / modified / orphaned orders, items added / changed / removed. Zero
writes — safe against production at any time.
bun packages/scripts/src/dev/replay-supplier-orders.ts \
--tenant-id <tenantId> --site-id <siteId> \
[--vendor-id <uuid>] \
[--from 2026-07-01] [--to 2026-07-31]
Run it before merging any change in the reordering blast radius:
packages/services-supplier-order/src/utils/auto-supplier-order-utils-new.tspackages/services-supplier-order/src/utils/supplier-order-utils-new.ts(thetrustedStockboundary)packages/services-supplier-order/src/utils/supplier-order-reconciliation.tspackages/services-supplier-order/src/utils/auto-supplier-orders-regeneration.tspackages/services/src/stock/stock-actions.service.ts- any code that touches
Product.stockor theStockActionlifecycle
Interpretation is simple: if your branch does not change business logic, the diff must be
identical to master's at the same instant. An unexpected delta (e.g. "−33 lines for this
vendor") is exactly the regression class no unit test spelled out — and the 07:15 reconcile
would have silently applied it the morning after the merge.
The daily health probe (06:30)
The supplier-stock-health task runs every morning at 06:30 (drop-in
/etc/cron.d/supplier-stock-health, read-only) — right before the
07:15 reconcile, to photograph the state before the day's
corrective pass. Its wrapper is the reference pattern: it runs the task file directly and
ends with exit $EXIT_CODE — failure genuinely reaches alerting.
What it evaluates (overall verdict ✅ HEALTHY / 🛑 ANOMALY, persisted to KV for the
control tower):
| Signal | Expected | Meaning |
|---|---|---|
reconcile.gateAccuracyPct |
high, stable | Share of supplier-order lines already matching the recomputed state, net of past-slot orphans. A sudden drop (85% → 6% observed on #2806) means a logic change just rewrote the order book |
| Phantom receptions | ≈ 0 (threshold maxPhantoms, default 50) |
PENDING projections of past-slot drafts that were never sent — the sweep must drain them |
supplier-orders-recalc queue |
ok |
Health of the event-driven chain (producers → bext queue → recalc under the write lock) |
backdatedDraftsCreated7d |
0 since the guard | Drafts created on a past slot — must stay zero under SUPPLIER_NO_PAST_SLOTS |
autoValidateSealed24h |
trends to 0 | See below |
| Reconcile accuracy | ≥ minAccuracy (default 50%) |
Non-zero exit below — the probe doubles as a pre-merge gate |
cd apps/app && bun run cli --task supplier-stock-health \
--tenantId <tenantId> --siteId <siteId> --json
autoValidateSealed24h — the forgotten-seals counter
Since #2886, the primary decrement gesture is the merchant's seal at the physical event
(delivery click in the UI, or "Validate today's route"); the auto-validate cron (12:01 + a
2-hourly sweep) is only a safety net that seals what was forgotten — see
write paths. autoValidateSealed24h counts the deliveries
the net had to seal in the last 24 h. An occasional non-zero is normal; a persistent
non-zero means operators are not sealing in the UI — the ledger stays correct, but with some
latency: once the AUTO_VALIDATE_GRACE_HOURS=12 grace clears, the 2-hourly sweep seals the
miss within ~2 h, and because the ~06:01 pass runs before the 07:15 reconcile, the morning
reorder pass normally does see the prior day's deliveries.
Runbook
Three decision trees for the recurring incidents. In every case: diagnose read-only first, heal second, pin a test at the end if a code path was at fault.
"A product's stock looks wrong"
flowchart TD
A["Displayed stock looks suspicious"] --> B{"manageStock ?"}
B -->|no| C["Convention: untracked product ⇒ 0<br/>trustedStock returns 0, nothing to heal"]
B -->|yes| D["Open the product timeline<br/>/manage/ecommerce/inventory/stock-ledger"]
D --> E{"Negative ledger?"}
E -->|yes| F["Real backorder — the next<br/>reception nets it: normal"]
E -->|no| G["stock-ledger-audit in report mode"]
G --> H{"I5 / I5b drifting?"}
H -->|yes| I["--apply — prod (backorder ON): resync stock mirror, no SA<br/>legacy (backorder OFF): Backfill SA + snaps stockLedger"]
H -->|no| J{"Faulty movement visible<br/>in the timeline?"}
J -->|yes| K["Write path bypassing the ledger:<br/>rule-17 violation → fix code + pin a test"]
J -->|no| L["Physical count<br/>/manage/ecommerce/inventory/counts"]
Step by step:
- Timeline first.
/manage/ecommerce/inventory/stock-ledger→ product → timeline. The divergent movement is almost always visible by eye (a delta with no matching reception, an unexpected ADJUSTMENT, a duplicated reception). - Audit in report mode (read-only) to qualify: I5 drift (counter ≠ Σ) or I5b (cache only)?
--applyonly rewrites broken rows. In prod (backorder ON), I5 resyncs theProduct.stockmirror only — no SA (snapping thestockLedgercache is I5b's job); in legacy (backorder OFF), it emits a "Backfill" reconciliation SA. The stock becomes consistent with the history — it does not become physically correct by magic: if the physical doubt remains, run a count.- If a code path bypassed the ledger (writing
Product.stockwithout aStockActionin the same transaction), that is a violation of the golden rule: fix the code and add a case tostock-write-math.test.tsor to the trust boundary.
"A supplier order is missing lines"
Model reminder: a "missing" line means the engine believes the demand is already covered — either by (over-)credited stock, or because the product is outside the scope of the cron that generated the order. The pessimistic kill switch produces the opposite effect (over-ordering): it is never the cause of missing lines.
flowchart TD
A["Missing lines in a supplier order"] --> B{"Product manageStock=false ?"}
B -->|yes| C["Only the 07:15 reconcile fills<br/>those products — check its last run"]
B -->|no| D{"Does credited stock cover the demand?"}
D -->|stock high| E{"Phantom stock?"}
E -->|yes| F["STOCK_TRUST_MAX_COVER_WEEKS = 8 ceiling<br/>+ /manage/ecommerce/vendors/orders/phantom-stock page"]
E -->|no| G["Audit I5 / I5b: inflated ledger?<br/>→ --apply then recalc"]
D -->|stock plausible| H["replay-supplier-orders<br/>read-only diff on the vendor"]
H --> I{"Does the diff show added items?"}
I -->|yes| J["The engine sees them: the 07:15 reconcile<br/>will add them — or trigger a recalc"]
I -->|no| K["Demand genuinely covered —<br/>a 100% quantity-0 batch is benign"]
Step by step:
manageStock=false? The auto-supplier cron (every 30 min) excludes those products; only the daily 07:15 reconcile (--apply --removeOrphans) fills them. If the order was inspected before 07:15, or the reconcile failed, the lines simply don't exist yet.- Phantom stock? The trust boundary caps trusted stock
at 8 weeks of recent consumption (
STOCK_TRUST_MAX_COVER_WEEKS=8) — but below that ceiling an inflated ledger still zeroes lines. The/manage/ecommerce/vendors/orders/phantom-stockpage lists the suspects (weeks-of-cover per product). - Targeted replay-diff (
--vendor-id): if the lines show up asaddedin the expected state, the engine agrees with you — it's a timing issue (the next reconcile/recalc will materialize them). If the diff is empty, the demand is genuinely covered. - A batch that is 100% quantity 0 is not an error — it's covered demand, materialized for information.
"A cron is failing silently"
The structural trap — now closed, but worth knowing: bun run cli --task X exits 0
unless the task throws, so a task that returns {success: false} is swallowed; and a
shell wrapper without set -e or a final exit $EXIT_CODE turns even a genuine non-zero exit
into 0. Historical result: the scheduler never alerted, and crons could "run" (log line
present) while failing for weeks.
✅ Both halves are fixed: the three gate tasks (auto-validate,
auto-supplier-orders-service, supplier-order-email) now throw — on whole-run
failure and whenever per-item failures remain (after the safe work) — and all 24 wrappers
propagate their exit code. The contract is pinned by cron-wrappers.shape.test.ts, which
fails any wrapper that swallows its exit code. The triage below still applies to failures that
don't travel through an exit code (cron uninstalled, stuck lock, box down).
flowchart TD
A["Suspicion: a cron stopped producing"] --> B["Cron-5 chip on the stock-ledger page<br/>(heartbeat) + 06:30 probe in KV"]
B --> C{"Chip state?"}
C -->|green ≤ 5h| D["The cron is running — look elsewhere<br/>(check 'last activity')"]
C -->|amber ≤ 12h| E["Several 2-hourly sweeps missed"]
C -->|red > 12h| F["The cron has not run:<br/>likely down"]
E --> G["tail the logs ~/.ploi/scheduled-*.log"]
F --> G
G --> H{"Error in the log<br/>but exit code 0?"}
H -->|yes| I["Exit-code masking (regression):<br/>wrapper without exit $EXIT_CODE,<br/>or task returning instead of throwing"]
H -->|no| J["Stuck lock? fuser -v on the lockfile<br/>+ replay in dry-run<br/>+ check the /etc/crontab entry"]
I --> K["Correct pattern: wrapper exit $EXIT_CODE<br/>+ gate tasks that THROW"]
Step by step:
- Chips first. The
/manage/ecommerce/inventory/stock-ledgerpage carries a health chip for the auto-validate cron. Its headline is liveness ("did the cron run?"), read from the heartbeat written on every run: ≤ 5 h green, ≤ 12 h amber, > 12 h red. The age of the last seal (reception / delivery) is demoted to secondary info ("last activity"): a quiet day produces no row, and that is not a failure. If the chip is red while the box is up, suspect an orphaned lock (a stuckbunholding theflockmakes every subsequent sweep skip):fuser -v /tmp/auto-validate-stock-actions.<TENANT_ID>.lock. - Logs. Every wrapper redirects to
~/.ploi/scheduled-*.logon the tenant box:
ls -lt ~/.ploi/scheduled-*.log | head
tail -100 ~/.ploi/scheduled-<id>.log
- Visible error + exit 0 = masking. Check the wrapper (
scripts/cron/*.sh): it must end withexit $EXIT_CODE(model:supplier-stock-health.sh), and any task used as a gate must throw on failure (model:reconcile-supplier-orders.ts, which throws on any per-row Prisma error instead of swallowing it). - Replay by hand in dry-run to reproduce — every cron has a documented smoke-test invocation in the daily timeline.
Never "fix" a mute cron by blindly re-running its --apply variant. Dry-run first, then the
audit (stock-ledger-audit), and only then the apply — in that order. A cron that missed
three days may have accumulated a backlog the --apply would process in one block.
The commandments (rule 17, condensed)
The NEVER/ALWAYS list distilled from real incidents — every line cost at least one production incident before being written down.
Never:
- Write
Product.stockwithout (a) checkingmanageStockand (b) emitting the matchingStockActionin the same transaction — otherwisestockLedgerdecouples (I5/I5b). - Reintroduce a stock decrement at order sync. That path was deleted on 2026-06-14: stock decrements on delivery only (UI seal, auto-validate safety net). A sync-time decrement would double-count with no shared idempotency signal.
- Create a
PENDINGStockActionfor amanageStock=falseproduct — it pollutes the ledger (the origin of the Bio Rennes case). - Transition an SA from
PENDING/COMPLETEDtoCANCELLEDwithout reversing the correspondingProduct.stockmovement for tracked products. - Stub
quantityBefore=0when creating an SA — read the realProduct.stockfirst. - Swallow per-item Prisma errors in a cron task — throw, and let monitoring catch it (that is the whole point of decision tree 3 above).
Always:
- Apply the trust boundary
manageStock ? max(0, stock) : 0(plus the consumption ceiling) when reading stock for reordering math. - Add a pinned case to
stock-ledger-trust-boundary.test.ts(or to the affected write path's test) for every newly discovered edge case. - Run the replay-diff before merging a change in the reordering blast radius.
- Register any new ledger task entry point in the CLI smoke test
(
packages/tests/tests/unit/cli/cli-smoke.test.ts).
See also
- Overview — the full stock → reordering → email model
- The stock ledger — signed authority, clamped mirror, backorder
- Write paths —
recordPhysicalMovementand sealing at the physical event - Supplier orders — event-driven recalc, trust boundary, gates
- Daily timeline — the crons, their schedules, their smoke tests
- PrestaShop sync — ledger-integrated sync writes
- Back office — timeline, counts, movements journal, phantom-stock page
- Configuration — all the flags (
STOCK_LEDGER_*,SUPPLIER_*,STOCK_TRUST_*)