Skip to main content

SOP: Expired & Recalled Product Handling

Document ID: WMS-INV-005 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Anyone responsible for ensuring expired or recalled product does not ship to customers, especially for lot-tracked categories (cannabis, vape, food, supplements)


1. Purpose

This procedure governs how the WMS handles product with limited shelf life or that becomes unsellable due to a manufacturer recall, regulatory hold, or vendor-issued withdrawal. Every other inventory SOP cross-references this one for the "do this first" path on lot-tracked product — WMS-INV-001 §4.3 (Mark Damaged) explicitly says expired or recalled product must follow this procedure before being marked damaged in the system.

The reality is that most of what a mature recall workflow should do is not built in the WMS today. The schema has the fields (trackLots, trackExpiry, lotNumber, expiryDate, InventoryStatus.QUARANTINE, LocationType.QUARANTINE); the FEFO allocation policy exists; the /inventory/expiring endpoint exists. But the operational glue — recall campaigns, lot-level holds, automatic quarantining of allocated stock, regulatory destruction logs — is not wired. This SOP documents what exists, what doesn't, and the manual procedures that bridge the gap.

⚠ Compliance disclaimer. This SOP describes how to operate the WMS. It does not constitute regulatory advice. State cannabis and vape regulations (in California, where the current operation is based, and elsewhere) impose specific requirements on recall response, destruction documentation, manifest creation, and witness signatures that go beyond what this WMS captures. Always consult your regulatory counsel and your METRC / state-equivalent procedures alongside this SOP. The system-of-record for regulatory compliance is your METRC reports, not the WMS.

2. Scope

In scope:

  • Variant-level configuration: trackLots and trackExpiry flags (per-variant, on the ProductVariant model)
  • Recording lot number and expiry date on inventory units (InventoryUnit.lotNumber, InventoryUnit.expiryDate)
  • The expiring-soon report (GET /inventory/expiring)
  • FEFO (First Expired, First Out) allocation behavior at order-allocation time
  • Manual quarantine via the QUARANTINE-named location (the only operational quarantine path today)
  • The recommended manual procedures for handling an expired unit, a vendor-issued recall, or a regulatory hold

Out of scope:

  • Per-unit damage marking after destruction is complete — see WMS-INV-001 §4.3
  • Receiving lot-tracked product — see WMS-REC-001 (lot number is captured at receiving line level if the variant has trackLots: true)
  • Customer return quarantine disposition (the only place QUARANTINE is operationally written today) — see WMS-RET-002
  • METRC manifest generation, regulatory tag handling, state compliance reporting — outside the WMS entirely
  • Vendor-credit tracking for recalled product — handled financially, not in the WMS

3. Roles & permissions

API enforcement: No endpoint in the WMS is specific to expiry or recall handling. The endpoints that touch the relevant fields are general-purpose:

  • GET /inventory/expiring — auth only
  • The cycle-count, floor-count, and inventory-unit move/adjust/damage flows — covered in their respective SOPs
RoleView expiring reportMark a unit's location → QUARANTINEInitiate a recall campaignSign destruction log
READONLY
STAFF✓ (via WMS-INV-001 §4.1 Move Location)
MANAGER✓ (manual procedure)✓ (paper)
ADMIN
SUPER_ADMIN

Operational expectations:

  • A recall campaign — the act of identifying, isolating, and destroying recalled stock — is a manager-led operation. STAFF should not initiate one solo because the regulatory documentation is the manager's accountability.
  • Two-person sign-off ("witness signature") is required for destruction of cannabis/vape product per most state regulations. The WMS does not capture or enforce this; paper sign-off is the control. See §6.

4. Procedures

4.1 Configuring a variant for lot or expiry tracking

Use when: Adding or editing a SKU that requires lot tracking (cannabis flower, vape carts, supplements) or expiry tracking (any time-sensitive product).

Prerequisites:

  • You're editing a ProductVariant either directly via the product UI or via the database.

The two flags:

FlagDefaultWhat it should do (per the schema's intent)
trackLotsfalseWhen true, every inventory operation should require a lot number; receiving, putaway, and counting should all carry the lot through.
trackExpiryfalseWhen true, every inventory operation should require an expiry date; FEFO allocation should activate.

⚠ The flags are not enforced in code today. The fields are written to the schema and migration; the repo and service layers expose them; but no code path checks them. You can:

  • Receive a trackLots: true variant with no lotNumber — the receiving line accepts null.
  • Receive a trackExpiry: true variant with no expiryDate — same.
  • Allocate a trackExpiry: true variant with the standard order-allocation flow — the allocation already orders by expiryDate ASC regardless of the flag (see §4.3), so FEFO is the de-facto default for all variants, not just expiry-tracked ones.

The flags are documentation today, not enforcement. They're useful for filtering reports and for human readers of the product catalog, but the WMS will not stop you from receiving or allocating a flagged SKU without lot/expiry data. Treat them as labels, not gates.

To set the flags: today, edit the variant directly in the database or via whatever product editor surface exists. There's no SOP-driven UI for toggling these flags; they're set during initial product import or one-off.

4.2 Recording lot and expiry at receiving

Use when: A receiving session has just landed product where the lot number or expiry date matters.

Where the data lives:

  • ReceivingLine.lotNumber — captured at receiving line level (one lot per line; if a single PO line has multiple lots, that's a real limitation — see §8).
  • ReceivingLine.expiryDate — same.
  • At approval (per WMS-REC-003 §4.2), these flow forward into InventoryUnit.lotNumber and InventoryUnit.expiryDate. If the line had no lot/expiry data, the resulting InventoryUnit has nulls.

Steps:

  1. During receiving (per WMS-REC-001 §4), enter lot and expiry on each affected line.
  2. Submit and approve the session normally.
  3. Verify the resulting InventoryUnit rows carry the lot and expiry — either via the unit detail page (/inventory/:id) or by querying the database.

⚠ The standard receiving UI may or may not surface lot/expiry inputs depending on UI build. If your floor's session UI shows fields for these, use them. If not, the data has to be patched in via SQL after approval, or via the unit-detail page edit (which itself has limited mutation affordances per WMS-INV-004 §4.2 for locations — and similarly limited for inventory units). For lot-tracked product, this is friction you'll feel; raise it as a UI gap.

4.3 The expiring-inventory report

Use when: Looking ahead to which units will expire soon, deciding what to discount, transfer, or destroy.

Steps:

  1. Call GET /inventory/expiring?days=30 (or any other window). Default is 30 days.
  2. The response is { daysUntilExpiry, count, inventory[] } where each inventory item includes the unit, its variant, and its location.

What it returns:

  • All InventoryUnit rows with status: AVAILABLE and expiryDate <= now + days. This means:
    • Units with expiryDate: null are excluded — even if trackExpiry: true (because the data is missing).
    • Units already in RESERVED, PICKED, DAMAGED, IN_TRANSIT, or QUARANTINE status are excluded.
  • The dashboard at /inventory shows the count under the Expiring stat card with subtitle "Next 30 days."

⚠ The Expiring stat card does not link anywhere. The card on the inventory dashboard renders the count but onClick={() => {}} — tapping does nothing. To see the actual list of expiring units today, call the API directly (browser DevTools, Bruno, etc.). No UI page renders the expiring list. This is a tractable engineering task — the API works fine. See §8.

4.4 FEFO allocation behavior

Use when: Understanding what determines which lot ships when an order is allocated.

How it works today:

The order-allocation service (packages/domain/src/services/order-allocation.service.ts) selects inventory for picking with this orderBy:

  1. expiryDate ASC — earliest expiry first
  2. receivedAt ASC — oldest receipt first (FIFO tiebreaker)

This is FEFO by default for all variants, regardless of trackExpiry. Units with expiryDate: null sort last (Postgres puts NULL after non-NULL on ASC by default in this codebase — verify in your DB if relying on this exact behavior). The picker walks to the bin with the earliest-expiring unit first.

What this means in practice:

  • A trackExpiry: false variant with one unit expiryDate: null and one unit expiryDate: 2027-01-15 will allocate the dated one first. This is correct FEFO behavior even though the flag is off.
  • A trackExpiry: true variant with all units expiryDate: null (because no one entered the data at receiving) will allocate by receivedAt ASC only — pure FIFO. Same as if the flag were off. The flag isn't doing anything here.

⚠ The standalone FEFOPolicy class in packages/domain/src/policies/allocation.policy.ts is unused. That class implements FEFO via expiryDate ASC, falling back to FIFO. No code path instantiates it. The order-allocation service uses inline orderBy instead. The policy classes (FIFO, FEFO, ZonePriority) are dead — they exist for some future allocation engine that's not wired today. The actual behavior comes from the inline orderBy clause in order-allocation.service.ts line 200.

4.5 Quarantining a unit (manual procedure)

Use when: A vendor recall comes through, a regulator issues a hold, you discover contamination on a specific lot, or expired stock needs to be segregated before destruction.

Prerequisites:

  • A Location with name: "QUARANTINE" exists. If it doesn't, the customer-intake flow auto-creates one (type: QUARANTINE, isPickable: false, zone: "SYSTEM") — but you should not depend on that. Verify via GET /locations?search=QUARANTINE and create it via WMS-INV-004 §4.1 if missing, with isPickable: false.

Steps:

  1. Identify the affected units. Query by lot number:
    SELECT u.* FROM inventory_units u
    INNER JOIN product_variants v ON u.productVariantId = v.id
    WHERE v.sku = '<sku>' AND u.lotNumber = '<lot>'
    AND u.status = 'AVAILABLE';
  2. For each unit, follow WMS-INV-001 §4.1 (Move Location) to move it to the QUARANTINE location. This produces an inventory:unit_moved audit row per unit (the audit-grade path) and is reversible if the recall is lifted.
  3. Do not mark the units DAMAGED yet — the recall might be lifted (manufacturer over-cautious, lot retested clean), and damage is a one-way door.
  4. Document the recall externally:
    • Vendor recall notice (PDF or email reference) saved to the regulatory drive
    • List of affected lots and quantities
    • Date of quarantine and the manager who initiated it

What the move achieves:

  • The units are at a isPickable: false location, so order-allocation won't pull them.
  • They retain status: AVAILABLE (because Move Location doesn't change status). The InventoryStatus.QUARANTINE enum value exists in the schema but is never written by any code path — see callout below.
  • The audit trail (per WMS-INV-001 §4.4) shows the move From/To with your user ID and the move time.

InventoryStatus.QUARANTINE is dead. The enum has the value but no code writes it. Quarantining is a location concern, not a status concern, in this system today. Don't try to set status: QUARANTINE via raw SQL — it's not what the application checks. The pickable-location filter is what blocks allocation, not the status.

⚠ The customer-return path is the only place QUARANTINE location is used operationally. When a customer return is dispositioned to QUARANTINE (per WMS-RET-002), the customer-intake routes auto-create the location and credit status: AVAILABLE units there. So the QUARANTINE location is shared between recall holds (manual) and customer-return holds (automated) — same physical zone, different reasons. Use lot number and receivedFrom to distinguish them in queries.

4.6 Lifting a quarantine

Use when: The recall is lifted, the regulatory hold is released, the lot has retested clean, or the held stock is otherwise cleared for resale.

Steps:

  1. Confirm the lift in writing — vendor email, regulatory release letter, lab certificate. Save to the regulatory drive.
  2. Decide the destination location. For most lifts, it's the original storage bin or the next-best storage bin per the variant's SkuLocationMap.
  3. Move each unit from QUARANTINE back to its destination via WMS-INV-001 §4.1.
  4. The audit trail captures both moves (into and out of QUARANTINE) with timestamps and user IDs.

⚠ A unit moved out of QUARANTINE is immediately allocatable. Once it lands at a pickable location, the next order-allocation pass picks it. If you want a buffer (manager review of the lift before stock returns to active selling), use a non-pickable transit zone first, then move to the pickable bin after sign-off.

4.7 Destroying expired or recalled product

Use when: A lot is confirmed unsellable — lift will not happen, expiry passed and stock cannot be sold to even discount channels, regulatory destruction order issued.

Prerequisites:

  • Units are in QUARANTINE per §4.5.
  • For cannabis/vape, your destruction must comply with state regulations: typically two-person witnessed destruction, video documentation in some jurisdictions, METRC tag voiding, manifest creation. The WMS does not enforce or capture any of this. Paper procedure governs.

Steps:

  1. Manager and witness coordinate the destruction physically per state regulation.
  2. Generate paper destruction log with: SKU, lot number, expiry date, quantity, destruction date, manager signature, witness signature, destruction method, METRC manifest reference.
  3. After physical destruction is complete and documented:
    • For each destroyed unit, follow WMS-INV-001 §4.3 (Mark Damaged) with reason Expired or Quality issue. The unit's status flips to DAMAGED and inventory_events records the destruction with your user ID.
    • The unit is now removed from any future allocation. The audit row preserves the lot number and expiry date.
  4. Save the paper destruction log to the regulatory drive. The WMS reference is the inventory_events row's timestamp and user — the paper sign-off is the regulatory artifact.

⚠ The damage reason Expired is a real reason code in WMS-INV-001 §5.2. Use it for expired product. For recalled product (different from expired), use Quality issue and explain in notes (e.g., "Vendor recall LOT-4471 — listeria contamination. Destroyed per state notice 2026-04-18."). Cross-reference the recall notice in the notes field.

⚠ The damage event does not link to the destruction log. There is no field on inventory_events for "destruction log filename" or "manifest reference." Add the cross-reference in the reason notes (free text). For a search-friendly audit, prefix every recall destruction reason with RECALL-{vendor}-{lot} so a WHERE reason LIKE 'RECALL-%' query surfaces all recalls.

5. Reference

5.1 What the schema models vs. what code uses

Schema field / enumOperationally used today?What uses it
ProductVariant.trackLotsNo — set on variants but never checkedDocumentation only
ProductVariant.trackExpiryNo — sameDocumentation only
InventoryUnit.lotNumberYes — recorded at receiving, carried through, used for filteringReceiving session lines, cycle-count lines, return restock
InventoryUnit.expiryDateYes — recorded at receiving, carried through, drives allocation orderOrder-allocation service orderBy clause, /inventory/expiring endpoint
InventoryStatus.QUARANTINENo — never written by any code pathDocumentation only — quarantine is a location concern
LocationType.QUARANTINEYes — used by customer-return intake for QUARANTINE-disposition splitscustomer-intake.routes.ts:317-322; the manual recall procedure (§4.5)
FEFOPolicy classNo — never instantiatedAllocation behavior comes from inline orderBy

5.2 Querying lot-affected units

For a recall response, the most common query patterns:

By lot number:

SELECT u.id, u.quantity, u.status, l.name AS location, v.sku
FROM inventory_units u
JOIN locations l ON u.locationId = l.id
JOIN product_variants v ON u.productVariantId = v.id
WHERE u.lotNumber = '<lot>';

By expiry window (manual variant of the API endpoint):

SELECT u.id, u.quantity, u.expiryDate, v.sku, l.name
FROM inventory_units u
JOIN locations l ON u.locationId = l.id
JOIN product_variants v ON u.productVariantId = v.id
WHERE u.status = 'AVAILABLE'
AND u.expiryDate IS NOT NULL
AND u.expiryDate <= NOW() + INTERVAL '<n> days';

Allocations against a lot (to identify orders that already pulled affected stock):

SELECT a.id, a.orderId, a.status, u.lotNumber
FROM allocations a
JOIN inventory_units u ON a.inventoryUnitId = u.id
WHERE u.lotNumber = '<lot>'
AND a.status IN ('ALLOCATED', 'PARTIALLY_PICKED', 'PICKED');

If a recall hits and there are matching allocations in PICKED status, those orders may have already been packed or shipped. Trigger your customer-notification process — that's outside the WMS.

5.3 The QUARANTINE location

Auto-created by customer-intake.routes.ts if missing, with name: "QUARANTINE", type: QUARANTINE, barcode: "QUARANTINE", isPickable: false, active: true, zone: "SYSTEM". If you don't want auto-creation behavior to surprise you, create the location explicitly via WMS-INV-004 §4.1 with the same parameters at warehouse setup time and pin a label on the physical zone.

There is one quarantine location per warehouse today — the customer-intake helper looks up by type: QUARANTINE and creates the first if missing. If you need separate quarantine zones for different categories (cannabis vs. vape vs. food), the system today does not differentiate. The workaround: name them QUARANTINE-CANNABIS, QUARANTINE-VAPE, etc., and bypass the auto-creation by always specifying location names directly. But the customer-intake auto-helper will still create a generic QUARANTINE if no location of that type exists, so order matters.

  • WMS-INV-001 §4.1 — Move Location (the workhorse for §4.5, §4.6 quarantine moves)
  • WMS-INV-001 §4.3 — Mark Damaged (the post-destruction step for §4.7)
  • WMS-INV-001 §5.2 — Approved damage reasons (Expired, Quality issue)
  • WMS-INV-002 — Cycle counts (catches lot/expiry data drift)
  • WMS-INV-004 §4.1 / §5.1 — Creating the QUARANTINE location, the LocationType enum reference
  • WMS-REC-001 §4.x — Receiving (where lot and expiry are first captured)
  • WMS-RET-002 — Customer return inspection (the only path that auto-credits to QUARANTINE)

6. Audit & compliance

The WMS captures partial audit data for the recall lifecycle:

  • Quarantine movesinventory_events rows of type inventory:unit_moved with the userId, From location, To location, timestamp. Reason is null (per WMS-INV-001 §4.1 — Move Location does not capture a reason). To make recall moves auditable, record the recall reference in a separate ledger, then cross-correlate by timestamp.
  • Destruction eventsinventory_events rows of type inventory:unit_damaged with the reason from the damage modal (per WMS-INV-001 §5.2). The reason field is free-text once you select the dropdown — you can append a recall reference (RECALL-LOT-4471 destroyed per state notice 2026-04-18).
  • Lot data on inventory — preserved across moves and damage; queryable via lotNumber field.
  • Variant flagstrackLots / trackExpiry are queryable on the variant, but as §4.1 notes, they're not checked by any code path.

The WMS does not capture:

  • Destruction witness signatures — paper only
  • Destruction date and method beyond what fits in the damage reason free text
  • Manifest references / METRC tags — paper only, METRC is the system of record
  • Recall campaign as an entity — there's no Recall table, no recall ID, no campaign-level grouping. Each affected unit is moved and (eventually) damaged independently.

For high-stakes audit (a state regulator audit of recall response), the WMS data is supporting evidence — the primary record is your paper destruction log + METRC manifest. A regulatory auditor asking "show me everything you destroyed from lot 4471" can reconstruct via:

  • inventory_events WHERE eventType = 'inventory:unit_damaged' AND payload->>'reason' LIKE '%LOT-4471%'
  • joined with the corresponding inventoryUnit rows for the lot

But this requires the destruction reason to consistently include the lot reference, which is operator discipline, not system enforcement.

Manager monthly review:

  • Pull the expiring report (/inventory/expiring?days=60) — what's coming due.
  • Pull inventory_events WHERE eventType = 'inventory:unit_damaged' AND payload->>'reason' = 'Expired' from the past 30 days — what was destroyed.
  • Pull units in the QUARANTINE location older than 30 days — investigate why they've been held that long.

Quarterly governance (Warehouse Operations Manager):

  • Total destroyed value (sum of unitCost * quantity from damaged events) per category
  • Recall response time (move-to-QUARANTINE → mark-DAMAGED) for any recall this quarter
  • QUARANTINE inventory aging — units sitting > 60 days indicate disposition is stalled

7. Troubleshooting

SymptomCauseResolution
/inventory/expiring returns inventory I expected to be excludedThe endpoint includes status: AVAILABLE only; expired units already in QUARANTINE/DAMAGED status are filtered outThis is correct. Adjust your mental model: the report is "what will need attention soon" not "all units past expiry ever."
Variant has trackExpiry: true but units have expiryDate: nullThe flag is documentation only; nothing enforces lot/expiry capture at receivingManual fix-up: query the variant's units, set expiryDate via SQL or via the unit detail page. Going forward, train counters to capture this data, and file the UI gap (per §4.2 callout) as engineering work.
Allocation is pulling expired stockAll allocation goes through expiryDate ASC, receivedAt ASC. If older-expiry exists, it should pick first. If it's not, check whether the unit's status is AVAILABLE (not RESERVED/PICKED already), location isPickable: true, and quantity > 0If all checks pass and FEFO still pulls wrong, escalate to IT — that's a real bug, not configuration.
Tapping the Expiring stat card does nothingThe dashboard card is a stub — onClick={() => {}}Use the API directly: GET /inventory/expiring?days=N. Build a UI page that consumes it (engineering task, see §8).
QUARANTINE location is missing from the warehouseCustomer-intake flow will auto-create it on first use, but you should not depend on thisCreate explicitly via WMS-INV-004 §4.1 with type: QUARANTINE, isPickable: false.
InventoryStatus.QUARANTINE showing in DB queryThis means someone wrote it manually via SQL — no application code doesInvestigate. The application reads status for allocation gating but not the QUARANTINE value specifically; behavior is undefined. Move the unit to a non-pickable location instead, then revert the status to AVAILABLE via SQL.
Multiple QUARANTINE locations exist (QUARANTINE, QUARANTINE-OLD, etc.)Auto-create looked up by type: QUARANTINE and may have created another if the original was renamed/deactivatedStandardize on one QUARANTINE location. Move stock from the duplicates to the canonical one (per WMS-INV-001 §4.1), then deactivate the duplicates per WMS-INV-004 §4.3.
Customer-return-quarantined units mixing with recall-quarantined units in the same physical zoneThey share the QUARANTINE location by design todayUse lot numbers and receivedFrom field to distinguish. Long-term: separate locations (named QUARANTINE-RECALL and QUARANTINE-RETURNS with override naming, since type: QUARANTINE is shared).
Recall came in for a SKU we don't have lot data forThe variant wasn't tracked or lot data was never captured at receivingWorst case for compliance. Manually identify by receivedAt window cross-referenced with vendor PO dates, manually quarantine those units. File the lot-tracking UI gap (per §4.2).
Damaged a unit with reason "Expired" and now want to undoDamage is one-way per WMS-INV-001 §4.3Cannot undo via UI. If physically the units are still good and the destruction was a mistake, escalate to IT. Database fix may be possible but produces an audit anomaly.

8. Escalation

  • No UI for the expiring report. The /inventory/expiring endpoint exists; no page consumes it. Build a /inventory/expiring route that calls the API and renders a sortable table with SKU, lot, expiry, location, quantity, days-until. Tractable engineering task; high operational value for cannabis/vape product. The Expiring stat card on the dashboard should link there.
  • trackLots / trackExpiry flags are documentation only. Decide whether to (a) wire them — make receiving reject lot-tracked variants without a lot number, allocation reject expiry-tracked variants without an expiry date — or (b) delete them from the schema as misleading. Either is fine. Documentation-only is the worst state.
  • No standalone recall workflow. A Recall model with recallNumber, vendor, lotNumber, affectedSkus[], status: ACTIVE | LIFTED | DESTROYED, linked to the affected units would make recall response auditable as a campaign rather than a series of independent unit moves. Significant feature work but high regulatory leverage for cannabis/vape clients.
  • Two-person sign-off on destruction. State cannabis regulations typically require it. The WMS doesn't capture it. A DestructionLog model with manager, witness, manifest, units[] would close this gap. Until built, paper sign-off is the control.
  • Multiple QUARANTINE locations for category separation. Today a single QUARANTINE location is shared between recall holds and customer-return-quarantine. If your operation needs separation (vape vs. cannabis vs. supplements), name them explicitly and bypass the auto-creation, or extend LocationType and the customer-intake helper to choose by category.
  • Suspected expired stock shipped to a customer. Stop allocations on the SKU immediately (move all units of the affected lot to QUARANTINE). Run the allocation query in §5.2 to identify orders that pulled affected stock. Notify the customer via your standard customer-service procedure. This is a recall scenario — file the regulatory notification per state law.
  • State regulator audit underway. Pull all inventory_events for the affected SKUs/lots in the audit window. Cross-reference with paper destruction logs and METRC manifests. The WMS data is supporting evidence; primary record is paper + METRC.

9. Revision history

VersionDateAuthorChanges
1.0[DATE][NAME]Initial release. Documents the lot/expiry/recall handling story as it actually works in the WMS today: (a) trackLots and trackExpiry flags exist on ProductVariant but no code path checks them — they're documentation, not enforcement; (b) InventoryUnit.lotNumber and InventoryUnit.expiryDate are real and propagate from receiving through allocation; (c) FEFO is the de-facto default for all allocations via inline orderBy: [{ expiryDate: 'asc' }, { receivedAt: 'asc' }] in order-allocation.service.ts:200, regardless of trackExpiry; (d) FEFOPolicy class in allocation.policy.ts is dead code — never instantiated; (e) InventoryStatus.QUARANTINE is never written by any code path — quarantine is a location concern, not a status concern, and the only operational write to a QUARANTINE-typed location is from customer-return intake (customer-intake.routes.ts:317-322); (f) /inventory/expiring API endpoint works but the dashboard's Expiring stat card has onClick={() => {}} — no UI route consumes the endpoint; (g) recall response is fully manual — no Recall model, no campaign tracking, no destruction-log capture, no witness-signature enforcement. Provides concrete manual procedures for quarantine (§4.5), lift (§4.6), and destruction (§4.7) using the working WMS-INV-001 unit operations as primitives. Cross-references WMS-INV-001, WMS-INV-002, WMS-INV-004, WMS-REC-001, WMS-RET-002. Code references: packages/db/prisma/schema/{products,inventory}.prisma, packages/domain/src/services/order-allocation.service.ts:200, packages/domain/src/policies/allocation.policy.ts (dead), apps/api/src/routes/customer-intake.routes.ts:14-66, 317-322, apps/api/src/routes/inventory.routes.ts:719-730, apps/web/src/pages/inventory/index.tsx:381-388, packages/db/src/repositories/inventory.repo.ts:202-220.