Flags & configuration

The stock & ordering subsystem is deliberately flag-governed: every behaviour change since the #2886 campaign shipped behind an environment variable that is off by default — flag OFF means byte-identical to the previous behaviour. This page is the exhaustive reference: for each flag, its semantics, default, production-profile value, and when (not) to flip it.

Note

The "production profile" is the production tenant (amisdelaferme), verified 2026-07-11. Other deployments start from the defaults (everything OFF) — by design: a fresh deployment behaves like the historical platform until you arm something.

Where the flags live

Flags are set in the deployment .env (apps/app/.env on the tenant box). That file is not versioned: it survives git reset and deploys. To read the current state:

grep -E "^(STOCK_|SUPPLIER_|AUTO_VALIDATE_|BEXT_|LEDGER_)" apps/app/.env

Two read families coexist — they determine how you apply a flip:

Family Flags To apply
Read at module load STOCK_LEDGER_PESSIMISTIC, STOCK_TRUST_MAX_COVER_WEEKS Process restart required
Read per call everything else Picked up on the next call in the affected process
Warning

The Next app bakes env at build time. On the tenant deployment, flipping a flag for paths served by the application requires a rebuild (next build), not just a restart. The crons, however, re-read the env on every run (bun auto-loads apps/app/.env when the cwd is apps/app) — so a flipped flag is often live on the cron side before it is on the app side. Account for that window during a flip.

A guard catches the "env silently not loaded" failure: if STOCK_LEDGER_PESSIMISTIC=false and STOCK_TRUST_MAX_COVER_WEEKS=0, the ordering engine logs a STOCK SAFEGUARD OFF warning (stockTrustConfigWarning(), packages/services-supplier-order/src/utils/supplier-order-utils-new.ts). That shape almost always means "the .env wasn't loaded", not a deliberate choice.

Which flags gate what

The pipeline reads as two halves: the write side (the physical movements feeding the ledger) and the read side (the supplier ordering that consumes it). Every annotated arrow is a checkpoint gated by a flag:

flowchart TB
  subgraph W["Write side — physical movements"]
    MV["Reception · delivery · adjustment<br/>(merchant seal, UI)"] --> RPM["recordPhysicalMovement<br/>(pure math)"]
    AV["Auto-validate cron<br/>(safety net)"] -- "AUTO_VALIDATE_GRACE_HOURS<br/>AUTO_VALIDATE_WRITES_STOCK" --> RPM
    PSY["PrestaShop product sync"] -- "RESET_STOCK_ON_SYNC<br/>(code constant, OFF)" --> RPM
    RPM -- "STOCK_LEDGER_ALLOW_BACKORDER" --> LG[("Product.stockLedger — signed<br/>Product.stock = max(0, ledger)")]
  end
  subgraph R["Read side — supplier ordering"]
    EV["Order events<br/>+ PrestaShop webhooks"] -- "BEXT_SUPPLIER_RECALC" --> Q[["supplier-orders-recalc queue"]]
    Q --> ENG["Recalc engine<br/>(per-site write lock)"]
    DEM["Client demand to cover"] -- "STOCK_FORWARD_DELIVERY_ONLY" --> ENG
    ENG -- "SUPPLIER_NO_PAST_SLOTS" --> SO["DRAFT supplier orders"]
  end
  LG -- "trustedStock():<br/>STOCK_LEDGER_PESSIMISTIC<br/>STOCK_TRUST_MAX_COVER_WEEKS<br/>STOCK_TRUST_ALLOW_NEGATIVE" --> ENG

Ledger — write side

These flags govern how a physical movement is recorded into the ledger. The underlying contract (every Product.stock write checks manageStock and emits a StockAction in the same transaction) cannot be turned off — it is structural. See Write paths.

Variable Default Prod Read Effect
STOCK_LEDGER_ALLOW_BACKORDER false true (since 2026-07-09) per call Signed ledger = authority; Product.stock = clamped mirror
STOCK_LEDGER_BACKORDER_CUTOVER 2026-07-09T20:00:18Z per call Time bound for the I6 audit counter
AUTO_VALIDATE_WRITES_STOCK true true each run false → auto-validate goes "paperwork-only" (no stock writes)
AUTO_VALIDATE_GRACE_HOURS 12 12 each run Hours before a planned date is presumed to have actually happened

STOCK_LEDGER_ALLOW_BACKORDER

The central Option B flag (#2886). OFF (default): writes are based on the Product.stock counter, clamped ≥ 0 — a manual decrement is capped to what actually exists, and the applied delta is recorded so counter and ledger move by the same amount. ON (prod): Product.stockLedger becomes the signed authority — a decrement records the true −quantity even below zero (a negative is a real backorder: delivered before the covering receipt was booked; the next reception nets it out), and Product.stock is written as the display mirror max(0, ledger). Phantom stock via clamping becomes structurally impossible.

  • Read site: allowBackorderEnabled() in packages/services/src/stock/rolling-stock-math.ts — read per call (tests flip it per case).
  • Under the flag, the I5 audit invariant reads stock == max(0, Σ COMPLETED) instead of strict equality — see Safety nets.
  • Companion: STOCK_LEDGER_BACKORDER_CUTOVER (ISO timestamp of the cutover) bounds the audit's I6 counter — "fresh clamps since cutover", which must stay at 0.
Warning

Do not flip this flag back OFF once the cutover has happened: writes after the cutover carry the signed semantics, and the Product.stock mirror has been rebased on it. Reverting would mix the two conventions inside the same history.

AUTO_VALIDATE_WRITES_STOCK

Controls whether the auto-validate cron (the safety net that seals forgotten receptions and deliveries — see Daily timeline) actually writes stock. The exact condition in packages/scripts/src/tasks/auto-validate-stock-actions.ts:

const stockWritesDisabled =
  process.env.AUTO_VALIDATE_WRITES_STOCK === "false" ||
  process.env.STOCK_LEDGER_PESSIMISTIC === "true";

So: enabled by default (only an explicit "false" disables it), and forcibly disabled in pessimistic mode. When off, the cron is still useful — it creates the SupplierOrderDelivery / OrderFulfillment records (the "paperwork") without touching Product.stock or the ledger.

AUTO_VALIDATE_GRACE_HOURS

The same cron's temporal guard. A deliveryDate / expectedDeliveryDate is a planned date, not proof a delivery happened: auto-validating same-day deliveries produced the 2026-06-10 incident (30 fulfillments + 20 phantom receptions). The cron only auto-validates items whose planned date is at least GRACE_HOURS in the past — at the default 12 h, the cron is safe at any run time or frequency. A merchant who delivers early simply confirms manually (through the UI, which the cron then skips via its idempotency guards).

Trust boundary — read side

These flags govern how much stock the ordering engine believes it holds when computing a supplier order. Everything flows through trustedStock() (packages/services-supplier-order/src/utils/supplier-order-utils-new.ts), the function pinned by the 77 stock-ledger-trust-boundary.test.ts tests.

Variable Default Prod Read Effect
STOCK_LEDGER_PESSIMISTIC false false module load Kill switch: stock ignored everywhere (→ 0)
STOCK_TRUST_MAX_COVER_WEEKS 0 (off) 8 module load "Phantom stock" ceiling in weeks of consumption
STOCK_TRUST_ALLOW_NEGATIVE false false per call Pass a negative ledger (backorder) through to ordering
LEDGER_AS_SOURCE_OF_TRUTH false true per call BO overview: start-of-period stock computed from the ledger

STOCK_LEDGER_PESSIMISTIC — the kill switch

truetrustedStock() returns 0 unconditionally: ordering covers the full client demand while ignoring stock entirely. This is the survival mode for when the ledger has known drift (sources disagreeing, un-reversed virtual entries, no recent physical count). Accepted risk: over-ordering for products with genuine stock — mitigated by draft review before sending.

When to flip to true: observed ledger drift, an ongoing data incident, or serious doubt during a migration. When to stay at false (current state): --strict audit green (I1–I5 = 0 violations), all write paths atomic and audited.

  • Side effect: it forces stockWritesDisabled in auto-validate (see above) — in pessimistic mode, nothing writes stock automatically anymore.
  • Read once at module load: flipping requires restarting the cron process and rebuilding the app.

STOCK_TRUST_MAX_COVER_WEEKS — the tunable knob

The anti-"phantom stock" ceiling. Some Product.stock values are deliberately inflated in PrestaShop (set very high so the storefront keeps selling at stock ≤ 0, then synced in): internally consistent (the I5 audit passes) but physically fictional. Without a ceiling they silently zero out supplier orders → stockouts.

When > 0, trustedStock() caps raw on-hand at N weeks of recent consumption (Σ COMPLETED DECREMENTs over the last 56 days, turned into a weekly rate by attachRecentWeeklyConsumption). Zero consumption ⇒ ceiling 0 ⇒ stock fully distrusted — the conservative direction (over-order rather than stock out). 0 / unset = disabled, zero extra queries.

The tuning trade-off:

  • Tight (2–4 weeks): catches more phantoms, but over-orders slow movers whose stock is nonetheless real.
  • Loose (12 weeks): only bounds the absurd (991 units on hand at ~1 sale/week).
  • Prod: 8 — to be relaxed (12 or off) once the ledger is proven clean over time.

Two per-product exemptions: data.stockTrusted (the merchant validated the count on the phantom-stock page of the back office → the real value passes uncapped) and madeToOrder vendors (just-in-time, no inventory → trusted 0 outright). The UI uses the same bar: phantomCoverBarWeeks() mirrors the flag (or 8 by default), so what the "Phantom stock" page flags is exactly what ordering would refuse to believe.

Tip

Tune this ceiling with data, not gut feeling: the backtest scripts backtest-ledger-flip.ts (A/B delta on the orders) and backtest-ledger-coverage.ts (per-product weeks-of-cover hit-list) replay production read-only.

STOCK_TRUST_ALLOW_NEGATIVE

The read side of Option B. OFF (default and prod): a negative ledger is clamped to 0 before the computation (the historical #2725 rule "negative stock = 0"). ON: a negative stockLedger passes through trustedStock() — a real backorder must increase the next order, not be ignored. The passthrough only applies to the ledger path (useStockLedger vendors); the Product.stock counter path stays clamped.

Flip guidance — strict prerequisites, in order:

  1. STOCK_LEDGER_ALLOW_BACKORDER=true already in place (otherwise a negative is legacy drift, not a backorder).
  2. ≥ 4 weeks of I6 counter = 0 (no fresh clamps since the cutover) and a green forward-check watchdog.
  3. Tests for the gated behaviour are already pinned — no code to write, only the flip.

LEDGER_AS_SOURCE_OF_TRUTH

A display/reporting flag, not ordering math: in the back-office stock overview (getProductStockOverview, packages/services/src/stock/stock-actions.service.ts), the start-of-period stock is computed from the ledger (Σ of StockActions before dateFrom) instead of Product.stock − Σ COMPLETED after. More robust as soon as the ledger is trustworthy — which is the case in prod (true).

Ordering pipeline

These flags govern which demand gets counted and where it materialises. Full context: Supplier orders.

Variable Default Prod Read Effect
BEXT_SUPPLIER_RECALC false true per call Event-driven recalc via the bext supplier-orders-recalc queue
SUPPLIER_NO_PAST_SLOTS false true per call Floor send slots at now + roll overdue demand forward
SUPPLIER_SLOT_FLOOR_GRACE_MIN 0 0 per call Grace (minutes) added to the slot floor
SUPPLIER_NO_PAST_SLOTS_SINCE 2026-07-10T19:45:00Z per call Excludes pre-guard rows from the health probe's gate
STOCK_FORWARD_DELIVERY_ONLY false true per call Only count demand with a future delivery
STOCK_FORWARD_DELIVERY_GRACE_DAYS 0 0 per call Grace (days) below the delivery floor

BEXT_SUPPLIER_RECALC

OFF: enqueueSupplierOrderRecalc() (packages/common/src/bext-sdk.ts) is a no-op — ordering lives off the 30-minute cron alone. ON: every producer (customer order create / update / cancel / delete / status change, supplier-order cancel, reception, PrestaShop webhooks, manual stock adjustment) pushes a scoped job into the bext supplier-orders-recalc queue. The producer is best-effort: deduped over a short window (a burst of changes on the same scope collapses into one job), never throws, never blocks the caller. The worker serialises against the cron via the per-site supplier write lock.

SUPPLIER_NO_PAST_SLOTS

OFF (default): the engine assigns overdue demand to its causal slot — even if it is already in the past (shown as "late"). ON (prod): three rules kick in — send slots are floored at now + grace (SUPPLIER_SLOT_FLOOR_GRACE_MIN), causality (send ≥ source order creation), and roll-forward: overdue demand rolls to the next future slot instead of being materialised in the past. The slot validator accepts late placement when the optimal send has already passed — without that, creation failed and the demand was dropped.

  • The historical replay (retrospective audit engine) explicitly pins allowPastSlots: true: it is immune to the guard, so its as-of-a-past-date analyses keep the legacy semantics.
  • SUPPLIER_NO_PAST_SLOTS_SINCE does not change engine behaviour: it bounds the health probe's gate so rows created before the guard was armed are not counted against it.

STOCK_FORWARD_DELIVERY_ONLY

OFF (default): empty filter fragment, zero change. ON (prod): demand discovery only counts client orders whose delivery is still ahead of usdeliveryDate ≥ floor (today, minus any STOCK_FORWARD_DELIVERY_GRACE_DAYS); orders without a deliveryDate fall back to invoiceDate against the conservative floor. Past-due unfulfilled demand is deliberately dropped (the merchant's ask). The paired sweep sweepStalePastDeliveryItems drains items already materialised before the floor — the DB filter and the sweep share the same floor function (forwardDeliveryFloor), so both agree on what "past" means.

Note: the UI's in-place recalc button computes forward-only explicitly (not via the flag), so it behaves the same regardless of the gate's state.

PrestaShop sync

Setting Type Value Effect
RESET_STOCK_ON_SYNC code constant (not env) false Who owns stock: the back office (false) or PrestaShop (true)

RESET_STOCK_ON_SYNC — careful, this is not an environment variable

Unlike everything else on this page, this setting lives in code: SYSTEM_CONFIG.PRESTASHOP_SYNC.RESET_STOCK_ON_SYNC in packages/common/src/utils/constants.ts. Changing it = commit + deploy.

  • false (current value): the back office is the stock authority. The product sync never rewrites an existing product's Product.stock, a new product starts at 0 (the merchant enters the real count), and the sync emits no ledger entry — fix #2798: when the sync does not own stock, it re-reads concurrent decrements (cron, manual edits) after the fact and would attribute them to itself as phantom "PrestaShop stock sync" entries, double-counting demand into the ledger.
  • true: PrestaShop is the authority. The sync resets Product.stock from PrestaShop and emits the matching COMPLETED StockAction (buildPsStockLedgerEntry, #2749 — OPENING_BALANCE for a new product, a PRESTASHOP_SYNC delta otherwise), so stock == Σ ledger holds.

In both positions, rule 17 holds: never a stock write without its paired ledger entry when you own the write; nothing at all when you don't.

Risky combinations

Shape Symptom Verdict
PESSIMISTIC=false + MAX_COVER_WEEKS=0 STOCK SAFEGUARD OFF warning at engine boot Almost always an unloaded .env — check the cwd/loading first
STOCK_TRUST_ALLOW_NEGATIVE=true without STOCK_LEDGER_ALLOW_BACKORDER=true Legacy-drift negatives inflate the orders Forbidden — the passthrough presumes Option B semantics
Re-flipping ALLOW_BACKORDER to false after the cutover History mixing two conventions Forbidden — see the warning above
Flag flipped in .env but app not rebuilt Cron and app disagree on the gate Flip window: flip, rebuild, restart, verify both

Flip checklist

Before flipping any flag on this page in production:

# 1. Ledger audit — 0 violations required
bun run cli --task stock-ledger-audit \
  --tenantId <tenant> --siteId <site> --strict

# 2. Replay diff (read-only) — the change doesn't move orders unexpectedly
bun packages/scripts/src/dev/replay-supplier-orders.ts \
  --tenant-id <tenant> --site-id <site>

# 3. The subsystem's pinned tests
npm run test:unit

After the flip: watch the daily supplier-stock-health probe (it must stay HEALTHY) and the audit invariants — see Safety nets.

See also