# LEDGER — Approval Engine → All Documents + Inventory

**Date:** 2026-07-13 · **Install:** moonui2 · BE `/home/moonui2/moon-erp-be` (`hazemdev2`) · FE `/home/moonui2/public_html/moon-erp` (`hazemdev2`) · Dev DB `moonui2_dev_be` (⛔ never fresh/wipe)
**Source plan (APPROVED):** `knowledge-base/plans/approval-all-documents-inventory-analysis.html`
**Start commits:** BE `dffacbd86` · FE `244b3aa84`

## 🎯 GOAL (owner acceptance — do NOT stop until met)
> كل المستندات في النطاق شغّالة صح بالاعتماد. **المستند المعلّق لا يحرّك مخزونًا ولا يعمل قيدًا — مُثبَتًا بالاختبار.** وصفر ارتداد: بلا ورك-فلو معرَّف ⇒ السلوك كما هو بالضبط. أمين المخزن يرى زرًّا واحدًا باسمه الحقيقي («استلام/صرف/ترحيل») و✅ الأخضر للاعتماد الإداري فقط. شاشة الإعداد لا تعرض نوعًا غير موصّل أبدًا.

## Decisions (APPROVED — do NOT re-litigate)
1. **Shape 1 «تخويل ثم ترحيل»**: `submit` لكل مستند (`submitForApprovalIfConfigured` + حالة `PendingApproval`)؛ الاعتماد عبر المحرك؛ زر التنفيذ الحالي (`approve()`/`confirm()`) يكتسب `assertApprovedForPost()` في أوله. **Shape 2 مرفوض** (تمرير `approve()` نفسه عبر المحرك يجعل ضغطة آخر معتمِد هي التي تحرّك المخزون ويفقد أمين المخزن دوره).
2. **إعادة التسمية**: الاستلام «استلام» · الصرف «صرف» · التسوية/الرصيد «ترحيل». ✅ الأخضر **يُحصر للاعتماد الإداري**. (مسمّى/أيقونة فقط — حالة الـBE تبقى `approved`.)
3. **النطاق**: ✅ التسوية · الصرف · الاستلام · GRN · إذن التسليم. ❌ **الجرد لا يُوصَّل** (`finalize` لا يحرّك مخزونًا؛ يُنتج «تسوية مسودة» — الرقابة عليها؛ توصيله = الازدواج الحقيقي). ⏸ **التحويل مؤجَّل** (المحرك عتبته مبلغ والتحويل بلا قيمة + نقطتا تنفيذ). ⏸ **الرصيد الافتتاحي مؤجَّل** (لا حالة مسودة — يحتاج إعادة هيكلة endpoint).
4. **DN/GRN يُوصَّلان لا يُحذفان** (التزام مالي حقيقي؛ «قابل للإعداد وبلا أثر» أسوأ من الغياب).
5. **المكوّن المشترك أولًا** (`shared/components/approval-actions/`) قبل أي شاشة جديدة — الكتلة منسوخة 8× وستصير 14×.
6. **مصدر واحد لأنواع المستندات**: `GET /core/approval-workflows/document-types` → `{module, document_type, labels, wired}`؛ الواجهة **تُخفي `wired=false`**.

## Settled facts (from the analysis — do NOT re-derive)
- `ApprovalWorkflowService::attachDocumentReferences` (:382-391) = 8 صفوف ثابتة و**تتجاهل المجهول بصمت** (:396-398) → كل نوع جديد يحتاج صفًّا وإلا الصندوق فارغ بلا خطأ.
- مستندات المخازن **بلا عمود `total`/`grand_total`** → accessor افتراضي `total` لكل موديل (سابقة `PurchaseRequest.php:128-136`). المحرك يقرأ `grand_total ?? total ?? 0` (`DrivesApprovalWorkflow:42-45`).
- **لا migration** لإضافة `PendingApproval` (عمود الحالة نصّي).
- أمر البيع وعرض السعر محميّان **بالصدفة** (`isDraft`) لا بحارس → أضف `assertApprovedForPost` (مجاني) في WP1.
- **الحرّاس الحالية:** `PurchaseBillController:442` · `PurchaseOrderController:391` · `:508` (convert) · `PurchaseReturnController:394` · `SalesInvoiceController:409` · `SalesReturnController:367`.
- **نقاط التنفيذ:** DN `confirm():303` (يخصم مخزونًا **فقط** لو `stock_deduction_point='delivery'`) · GRN `approve():401` (يحرّك مخزونًا `:536` + **قيد GR-IR** `:539`) · Receipt `approve():429` · Issue `approve():225` (**قيد COGS**) · Adjustment `approve():146` (**بلا قيد — ثغرة قائمة**).

## Mandatory edge cases (test them)
- `inventory.receipt_auto_approve` **يجب ألّا يتخطّى الدورة** (الأولوية للاعتماد).
- GRN بمسار `PendingReceipt` = **اعتماد واحد لا اثنين** (الاعتماد على GRN، والاستلام يتبعه).
- إذن التسليم في وضع «الخصم عند الفاتورة» → الاعتماد تخويل للالتزام، لا حماية مخزون. يُوثَّق.

## WP table
| WP | Scope | Repo | Depends | Review | Migration? | Status | Commits |
|----|-------|------|---------|--------|-----------|--------|---------|
| WP1 | **الطبقة المشتركة أولًا**: (أ) `GET /core/approval-workflows/document-types` (مصدر واحد + `wired`)؛ (ب) `assertApprovedForPost` لأمر البيع + عرض السعر (الحارس المجاني)؛ (ج) مكوّن FE مشترك `shared/components/approval-actions/`. الشاشات الثمانية الحالية تعمل **كما هي بالضبط**. | BE+FE | — | Codex+Fable | no | ✅ done | BE `c09eead21` + engine fix `f70098679` · FE `d86e0225c` · Fable APPROVE · 7+16 new tests · zero new failures · /app deployed |
| WP2 | **[FIN] التسوية + الصرف**: `PendingApproval` + `submit` + حارس على `approve()` + accessor `total` + صف في `attachDocumentReferences` + إعادة التسمية. | BE+FE | WP1 | Codex+Fable | no | ✅ done | BE `0725e7864`+fix `de631e952` · FE `f246964de`+`a8c0344c2` · Fable CHANGES-NEEDED → all applied · 22 tests |
| WP3 | **[FIN] الاستلام + GRN**: نفس النمط. مراعاة `PendingReceipt` (اعتماد واحد) و`receipt_auto_approve` (لا يتخطّى). إعادة تسمية «استلام واعتماد» → «استلام». | BE+FE | WP2 | Codex+Fable | no | ✅ impl done, Fable pending | BE `dffadb45b` · FE `dd2b0b242` · 20 new tests · zero new failures |
| WP4 | **[FIN] إذن التسليم**: حارس على `confirm()` فقط (لا ship/deliver). + توصيل شاشتي DN/GRN بالمكوّن المشترك. | BE+FE | WP1 | Codex+Fable | no | ✅ impl done, Fable pending | BE `2c7254c43` · FE `e7bb8a8f3` · 10 new tests · zero new failures |
| WP5 | **شاشة الإعداد + الصندوق**: موديول «المخازن»؛ الشاشة تُغذّى من endpoint الأنواع؛ **إخفاء غير الموصّل**؛ خرائط label/route في الصندوق (نوع غير مُعرَّف = لا رابط للمعتمِد). | FE | WP1..WP4 | Codex | no | ⬜ | |
| WP6 | **الصلاحيات**: كل حركة اعتماد لها صلاحيتها ومنفَّذة. + إصلاح زر اعتماد الرصيد الافتتاحي المبوّب خطأً على `inventory.opening.create`. | BE+FE | WP5 | Codex | no | ⬜ | |

## DAG / serialization
- **One writer per repo at a time.** WP1 is BE+FE (do BE part, then FE part).
- WP2 → WP3 serialize (both BE+FE, overlapping trait/enum surface).
- WP4 depends only on WP1 → may run after WP1, but serialize against WP2/WP3 on the FE tree.
- WP5, WP6 last.

## Baseline (recorded 2026-07-13 — Inventory + Purchases + Sales suites)
**19 failed / 1105 passed (4222 assertions).** ALL PRE-EXISTING — do NOT blame this feature:
1. `PurchasesSettingApiTest` **×8** (list/defaults/update/persist/grn_mode-enum/approval_workflow-enum/payment-terms/company-scope) — `UniqueConstraintViolationException` on `setting_definitions.setting_key` for `purchases.grn_mode`. **Same family as SettingApiTest/ProductApiTest**: the test `create()`s a definition the moonstack bridge migration already seeds during `migrate`. **Verified pre-existing:** `purchases.grn_mode` is seeded by Core's `SettingDefinitionSeeder` at `0a587a82~1` (before the permissions feature), and that feature never touched this test file.
2. `CogsAtDeliveryCancelTest` **×6** (cancel-1/2/3/4/4b/6) — known pre-existing (also seen in the permissions feature's baseline).
3. `it requires customer_id and branch_id when no invoice_id` ×1 · `it requires supplier_id and branch_id when no bill_id` ×1.
4. `it blocks update of a GRN-linked receipt with receipt_locked_by_grn` ×1 · `it blocks destroy …` ×1.
5. `opening balance creates inventory movement with type opening` ×1.
Also known outside these suites (from the previous feature): Core — `AdminApiTest` import ×1, tenant-scoping ×3, `SettingsServiceTest` enum ×1, `UserApiTest` create-with-role ×1, `SessionApiTest` login-meta ×1; LIS — `LabRequestApiTest` ×3, `LabResultApiTest` ×2, `LisCatalogReplaceTest` ×2, `LabReportPdfParityTest` ×1.
**Gate for every WP: no NEW failures beyond these.**

## Deferrals (explicit — never claim these are done)
- **التحويل المخزني** — يحتاج **عتبة كمّية في المحرك** (تغيير في المحرك لا في الكنترولر) + له نقطتا تنفيذ (`ship`+`receive`).
- **الجرد** — **عمدًا لا يُوصَّل** (توصيله خطأ: لا يحرّك مخزونًا؛ الرقابة على التسوية التي ينتجها).
- **الرصيد الافتتاحي** — `store()` ينشئ ويعتمد في نفس الترانزاكشن؛ يحتاج إعادة هيكلة endpoint إلى (إنشاء مسودة + اعتماد). WP منفصل.
- **التسوية بلا قيد محاسبي** — ثغرة قائمة سابقة (تحرّك مخزونًا بأثر GL صفر). خارج نطاق هذه الجولة؛ تُسجَّل للمالك.

## Progress log
- 2026-07-13: Phase A — workspace + LEDGER created; baseline running.

- 2026-07-13: WP1 🔄 started (document-types endpoint + free guard + shared approval-actions component).
- 2026-07-14: WP1 implemented (BE `c09eead21`, FE `d86e0225c`) — awaiting review gate.
  - Endpoint `GET /core/approval-workflows/document-types` — `wired` derived from a SINGLE source: the `attachDocumentReferences` map was extracted to `ApprovalWorkflowService::documentReferenceMap()` (public static) + `isWired()`. No second list. Test asserts wired == array_key_exists for every case; `delivery_note`/`grn` = false.
  - Guards added: `SalesOrderController::confirm()` + `SalesQuotationController::accept()`. Now-unreachable `hasPendingApprovalLog()` blocks below them removed (dead code).
  - Shared `<app-approval-actions>` created; 8 screens refactored (−504/+72 lines).
  - **DEVIATION (implementer corrected my brief — accepted):** my brief claimed the inline ✓/✕ act block was copy-pasted on all 8 screens. **False — only sales quotations + orders had it.** The other 6 (sales invoices/returns, purchases requests/orders/bills/returns) had only the BADGE, plus their own document-level approve/reject on their own permissions (`sales.invoices.approve` etc.). Giving those 6 inline approval-log ✓/✕ would have been a BEHAVIOUR CHANGE, so they pass `[actions]="false"` and keep their own buttons. Correct call — the WP contract was "zero behaviour change".
  - **Behaviour delta flagged:** with a workflow configured, the blocked-while-pending 422 body for order-confirm/quotation-accept changes shape (`approval_pending_block_action` → ValidationException `approval_pending_block_post` + `errors.approval`). Still 422 with a translated message the FE toasts. Inherent to running the guard first.
  - Implementer proved the Core (7) and Purchases (11) failures pre-existing by `git checkout HEAD~1` + re-run — identical. **Zero new failures.** All approval suites pass.
- 2026-07-14: ⚡ **PERF FIX (`19aecd566`) — and MY DIAGNOSIS WAS WRONG, recorded honestly.** Two fleet-update bridges ran `GrantRolePermissionsSeeder->run()` inside `up()` with no test guard; `RefreshDatabase` re-runs `migrate` per test class (569 files use it), so every class re-seeded ~1000 permissions + all roles. I predicted this was THE cause of the 59-minute suite (~3.1s/test) and would give a 10× win.
  **A/B measured on PurchaseGrnApiTest (24 tests): 73s without the guard → 68s with it. A ~7% win, NOT 10×.** The dominant per-class cost is the MIGRATION RUN itself (hundreds of migration files), not the seeders. I over-claimed from a plausible-looking cause without measuring first.
  Kept the guard anyway — the bridges genuinely have no business running under tests, it costs nothing, and 24/24 pass both ways. **The real lever for test speed is `artisan test --parallel` or a schema dump — neither done, both out of scope.**
  - ⚠️ Process lesson: I edited these files while the WP1 agent still held the repo, and its `git checkout HEAD~1 -- Modules/Core` (used to prove failures pre-existing) **wiped my uncommitted edits**. Re-applied after it committed. **Never edit a repo an agent is writing to.**

## WP1 review gate (2026-07-14)
**Fable [FIN]: APPROVE** — WP1 itself is correct and behaviour-preserving. All 8 wired types verified to have a real controller guard (none is map-row-only), so `wired` is honest. The two new guards are placed before any state write; the removed `hasPendingApprovalLog()` blocks were genuinely unreachable (same predicate). The 422 shape change has **zero** FE consumers of the old shape (grepped). The shared component is verified byte-identical in behaviour on all 8 screens.
**BUT it found TWO PRE-EXISTING BYPASS HOLES in the engine — the approval system is bypassable TODAY (Sales + Purchases, already live):**
- 🔴 **P1 — Rejection is ADVISORY.** `ApprovalWorkflowService::reject()` only flips `approval_logs` to Rejected; **the source document is never touched**. A rejected sales order stays `draft` → the user retries Confirm → `assertApprovedForPost` only blocks on a **pending** log (none now) → guard passes; and `hasApprovalLogs()==true` skips re-submission → **the rejected document COMMITS.** Same for quotation accept. **For the WPs we are about to build: a rejected stock adjustment would post and destroy stock (and an issue would post COGS) on a simple retry.**
- 🔴 **P2 — The workflow is BYPASSABLE FROM THE API.** `InvoiceStatus::canApprove()`, `PurchaseBillStatus::canApprove()`, `PurchaseReturnStatus::canApprove()` all allow **Draft**. A caller with the doc's `.approve` permission can approve a draft directly → `submitApproval` never ran → no engine logs → engine branch skipped → straight to Approved → then `post()` passes. **The configured multi-level workflow is bypassed server-side.** (The FE hides the button — but the FE is not a security boundary.) Sales Return / PR / PO are safe (their `canApprove()` requires PendingApproval).
- P3 (design note for the stock/GL WPs): run `assertApprovedForPost` INSIDE the posting transaction — the current assert-then-write has a TOCTOU window that is harmless today but matters once stock/JEs sit behind it.
- P0 (done): the WP1 FE build had never been deployed to `/app`. Deployed (`main-XJ6GTHWC.js`).
→ **Both holes are being closed in the SHARED layer BEFORE WP2** — any guard we add to inventory would otherwise inherit them. Fix agent dispatched.

## 🔒 ENGINE BYPASS HOLES CLOSED (`f70098679`) — before WP2, in the SHARED layer
All three are PRE-EXISTING (live on Sales+Purchases today), not regressions. Fixed before building inventory guards on top, because those guards would have inherited every one.
- **HOLE 1 — rejection was ADVISORY.** Fixed via a new nullable `superseded_at` column on `approval_logs` (migration `2026_07_14_600020`, additive, applied to dev) giving the engine an explicit **cycle**. Current cycle = rows with `superseded_at IS NULL`. `submitForApproval()` supersedes any still-active rows first, so an old cycle can never gate anything. `assertApprovedForPost()` now: empty cycle → no-op (unchanged); any Pending → 422 (unchanged); **any Rejected → 422 with the NEW key `approval_rejected_block_post`**. Old rows kept (audit).
  - **Resubmission story: editing the document supersedes its rejected cycle** (`supersedeRejectedApprovalCycle()` at the end of `update()` in all 8 controllers). Deliberately narrow: fires only when the cycle has a Rejected row and NO Pending row — never discards an approved cycle, never cancels a pending one behind the approver's back. Chosen over a new endpoint because it needs **zero FE work** (a rejected doc reports `state:'none'` so its Edit button is already live) and matches intent (the approver rejected *this version*).
  - **Actually exploitable before the fix: sales ORDER (`confirm`) and QUOTATION (`accept`)** — they have no `Approved` status to pass through, so a plain retry committed a rejected doc. The other 6 were *incidentally* protected (their commit requires status Approved); now protected **explicitly**.
- **HOLE 2 — draft-approve API bypass.** Confirmed: `InvoiceStatus`/`PurchaseBillStatus`/`PurchaseReturnStatus::canApprove()` allowed Draft (ReturnStatus/PurchaseOrderStatus/PurchaseRequestStatus correctly required PendingApproval). Fixed with **option (b) force-submit inside `approve()`** (`submitBeforeApproveIfBypassing()`), NOT by tightening `canApprove()` — tightening would have **broken the real, used no-workflow flow** (draft → approve → post in one click). With no workflow the engine returns `auto_approved`, writes **zero rows**, and the draft approves exactly as today (cost: one extra SELECT).
- 🔴 **HOLE 3 — found by the implementer, NOT in the brief: approving the last level from the "My Approvals" INBOX BRICKED the document.** `ApprovalLogController::approve` cleared the engine but never advanced the source document; the document's own `approve()` then found no pending log and `abort(403)` → an invoice/bill/return whose approvals were all in **could never leave `PendingApproval`**. The inbox is the PRIMARY approver UI, so this was a live brick on the main path. `approveViaEngine()` now returns `'fully_approved'` when the current cycle is fully approved (letting the controller advance the doc) and still 403s a cycle carrying a rejection.
- Also fixed: `history` now includes superseded rows flagged `superseded: true` (audit), while `state`/`can_act` use the CURRENT cycle only — which also killed a latent bug where a stale rejected row forced `state:'none'` on a re-approved document.
- **Tests: new `ApprovalBypassGuardTest` 16/16.** SIX of them are the sacred invariant with NO workflow (draft invoice → approve → post writes the JE; bill; purchase return; order confirm; edit) — each asserts `ApprovalLog::count() === 0`, i.e. the engine writes nothing at all. Plus 55 existing approval tests pass. Core 7 / Sales 7 / Purchases 11 failures = **exactly the known baseline**. Zero new failures.
- **NOT done (FE follow-up, recorded):** a rejected cycle still reports `state:'none'` to the FE (that is what keeps the Edit button live and made the fix BE-only). The user learns of the rejection from the new 422 message. A red "Rejected — edit to resubmit" badge in `approval-actions.component` would be a nice follow-up.
- 2026-07-14: WP1 ✅ CLOSED. WP2 🔄 started (adjustment + issue).

## WP2 implemented (BE `0725e7864` · FE `f246964de`) — in [FIN] review
- Guard placement verified: `submitBeforeApproveIfBypassing()` → **`assertApprovedForPost()`** → `try { $action->execute() }`. The stock-moving action is never constructed. Same in both controllers.
- 🔴 **THE CRUX — a trap that would have made the whole feature useless exactly where it matters most.** `inventory_adjustments.total_cost_impact` is a **SIGNED NET** (`costIncrease − costDecrease`). A pure write-off — **the exact fraud vector this WP exists to stop** — is **NEGATIVE**, so `min_amount <= amount` matches no level → `reachedLevels` empty → engine returns **`auto_approved`** → **the most dangerous adjustment escapes the gate entirely.** The implementer used the **gross absolute** (`sum(abs(item.total_cost))`) for the threshold instead. Test pins it: `total === 1000.0` while `total_cost_impact === -1000.0`. (Inbox still DISPLAYS the signed impact — sent to Fable to rule on whether that split is defensible.)
- **Issue threshold is an ESTIMATE at submit:** `inventory_issues.total_cost` is 0 for a draft (priced WAC/FIFO only at posting), so the accessor prices lines via read-only `StockService::getIssueCost()` and `submitApproval()` stamps it; posting overwrites with the actual. **Asked Fable: can the actual exceed the estimate enough to cross a threshold the approver never saw?** (would be a real control failure).
- `documentReferenceMap()` rows: adjustment → `(inventory_adjustments, adjustment_number, total_cost_impact)`; issue → `(inventory_issues, issue_number, total_cost)`. Catalog test updated 10→12 cases, 8→**10 wired** (THREE hardcoded counts existed, not two).
- Action guards moved `isDraft()` → `canPost()` (Draft OR PendingApproval) — needed so the keeper can still post after submit. Claimed strict no-op for internal callers (Production IssueMaterials, Sales ConfirmDeliveryNote, Purchases PostPurchaseReturn all create Draft issues with no engine rows) — **Fable verifying**.
- Governance approve makes NO status change (authorized doc stays `pending_approval` until the keeper posts); governance reject returns to `draft`.
- **PROOFS (quoted from the tests):** pending ⇒ `inventory_movements`=0, `journal_entries`=0, balance unchanged (100.0), `cogs_journal_entry_id` null. No-workflow ⇒ `ApprovalLog::count()===0` after BOTH submit and approve, balance 95.0, exactly 1 movement.
- Tests: 11 new green · Inventory 1 failed/389 passed (the 1 = known `opening balance…` ) · ApprovalBypassGuard+Catalog 19 passed · Sales 7 failed/356 (exactly the known list). **Zero new failures.**
- FE: shared `<app-approval-actions>` in both list rows; submit button when `draft && approval.required`; **commit renamed + de-greened**: adjustment → «ترحيل/Post» (`pi-arrow-right-arrow-left`, contrast), issue → «صرف/Issue» (`pi-sign-out`, contrast). `/app` deployed (`main-7HGVWOWD.js`).
- **Deferral recorded (pre-existing, system-wide — NOT introduced here):** rejecting from the generic *My Approvals* inbox flips `approval_logs` but does not change the source document's status — true for **all 10 wired types**. Nothing bricks (the commit 422s on the rejected cycle; re-submit opens a fresh one), but the doc's status doesn't reflect the rejection. Worth a separate WP (a `DocumentRejected` event in Core).
- Note: adjustments have **no update route**, so a rejected adjustment is released by re-submitting or cancelling — it cannot be edited. Pre-existing surface, not widened.

- 2026-07-14: WP3 🔄 started (Inventory Receipt + Purchase GRN) — in parallel with WP2 Fable review (read-only, no writer conflict).

## 🔴 WP2 [FIN] REVIEW — CHANGES-NEEDED (Fable). Fix queued behind WP3 (one writer per repo).
**Verdict: shape is right, engine wiring correct, invariant tests are the STRONG form (table-wide zero counts, not scoped) — but ONE HOLE IN THE EXACT VECTOR THIS WP EXISTS TO CLOSE.**

- ✅ **Gross-absolute threshold CONFIRMED correct, definitively.** Fable verified `submitForApproval()` (:65-73) matches `min_amount <= amount`; a negative/zero amount → empty `reachedLevels` → `auto_approved`, zero rows. So signed-net would let a pure write-off escape. **And `abs(net)` is ALSO wrong** — a mixed +500/−500 nets to 0 → auto-approved, and it invites **STRUCTURING** (pair a big write-off with an offsetting fake increase). Gross absolute = value of stock actually moved = the only honest gate.
- ✅ `canPost()` widening verified SAFE (all internal callers create Draft docs that can never carry engine rows: PostSalesInvoice, Production IssueMaterials; ConfirmDeliveryNote/PostPurchaseReturn/BackflushFromStaging only mirror via StockService and never call the action).
- ✅ The invariant is genuinely proven (table-wide `ApprovalLog::count()===0`, `inventory_movements` 0, `journal_entries` 0, balance re-read).

### 🔴 P1 [CRITICAL] — THE GATE CAN BE BOUGHT FOR A PENNY
`StoreInventoryAdjustmentRequest` accepts **user-supplied `items.*.unit_cost` (gte:0 — zero allowed)**; the controller stores `total_cost = qty × unit_cost`; `InventoryAdjustment::getTotalAttribute()` sums those; **and `ApproveAdjustment` moves stock at that same user cost.**
**Failure:** workflow gates adjustments at `min_amount 500`. A user with `inventory.adjustments.create` raises a DECREASE of **5,000 units × unit_cost 0.01** → gross total = **50** → below every level → `reachedLevels` empty → **`auto_approved`, ZERO approval_logs** → posted in one click → **5,000 units leave the warehouse with no second signature.** The precise shrinkage vector this WP was built to close, **reopened through the cost field.**
**Fix:** in `Modules/Inventory/app/Models/InventoryAdjustment.php::getTotalAttribute()`, price **decrease** lines at SYSTEM cost (like `InventoryIssue::$total` already does): `max((float)$item->total_cost, StockService::getIssueCost(...)['total_cost'])`. Keep user cost for INCREASE lines (no system cost exists yet, and overstating an increase only raises the threshold — the safe direction). **Test: decrease 5,000 × 0.01 with WAC 100 must create a pending log, not auto-approve.**
(That posting also *values* the movement at the user's cost is a pre-existing valuation issue — out of scope — but the THRESHOLD must not trust it.)

### P2 [HIGH] — a document rejected from the INBOX is stranded with no UI escape
`ApprovalLogController::reject` flips logs only; the doc stays `pending_approval` (only WP2's own `rejectApproval` endpoint sets Draft). The cycle then reports `state:'none'`. FE: Submit needs `status==='draft'` → hidden; edit/delete need draft → hidden; an issue isn't cancellable from pending. **The keeper sees only a Post button that 422s forever.** The BE already accepts the escape (`$resubmittable = isPendingApproval && !hasPendingApprovalLog`) — the FE just has no button. Fix: one condition in `stock-adjustments.component.html` / `stock-issues.component.html` — also show Submit when `status === 'pending_approval' && approval?.required && approval?.state === 'none'`.

### P3 [MEDIUM] — a FULLY-APPROVED, unposted document can be silently re-submitted, voiding the signatures
`$resubmittable = isPendingApproval && !hasPendingApprovalLog` is TRUE for an all-approved cycle awaiting post, and `submitForApproval()` supersedes it unconditionally — contradicting the trait's own promise that an approved cycle is never silently discarded. Anyone with `.create` can reset a CFO-signed cycle. Fix: `$resubmittable = isPendingApproval && (!hasApprovalLogs || hasRejectedApprovalCycle)`.

### P4 [MEDIUM — follow-up WP] — the issue estimate/actual gap
Quantities freeze after submit (`update()` requires `isDraft()`), so the requester can't inflate it alone — **but WAC is recomputed by receipts landing between submit and post, and FIFO layers are consumed by other issues.** A doc estimated at 9,500 (level 1, max 10,000) can POST at 13,300 (level-2 territory) **with level 2 never seeing it.** Second-order (needs an independent costing event) but real. Fix: re-price at `approve()` and 422 `approval_amount_changed` if the re-priced amount reaches a level the approved cycle doesn't contain.

### P5 [LOW] — cancelling a pending adjustment leaves live pending logs
`cancel()` never closes the cycle → a forever-pending row in every approver's inbox for a cancelled doc. Fix: supersede the cycle on cancel. (No stock risk — `canPost()` excludes Cancelled.)

**P6 (noise, not a defect):** the 3 catalog-test failures Fable saw are WP3's WIP adding a 13th type — WP3 must bump the counts.
→ **Fix agent for P1–P5 dispatches the moment WP3 releases the repo.**

## WP3 implemented (BE `dffadb45b` · FE `dd2b0b242`) — Fable [FIN] still owed
- **GRN guard at `:562/:566`** — precedes the `DB::transaction` (`:614`) AND the `grnReceiptRequiresApproval` fork (old `:524`, now `:673`), so it covers BOTH branches, the receipt creation, `ApproveReceipt` (stock + event) and `FinalizeApprovedGrn` (PO qty + **GR-IR JE**). Sits after only the two pure-422 validations (so a QualityRejected GRN can't be submitted to the engine).
- **Receipt guard** immediately after `findOrFail`, before `ApproveReceipt`.
- **`total` accessor = `total_cost` on BOTH** — verified against `recalculateTotals()` (`sum(items.total_cost)`, each line `qty × unit_cost`) and its call sites: stamped on the DRAFT before the auto-approve branch. **Always positive** (no signed-net trap) and **not zero-until-posted** (no costing run needed, unlike the issue). Asserted on the draft: `total === 1500.0`.
- **EDGE CASE 1 — auto-approve does NOT bypass.** Implemented: `store()`'s auto-approve branch now offers the receipt to the engine FIRST; only an `auto_approved` return (no workflow ⇒ zero logs) falls through to `ApproveReceipt`. Test asserts pending + `inventory_movements` 0 + `journal_entries` 0 + `StockBalance::count() === 0` + commit 422 until signed.
  - ⚠️ **Pre-existing FE/BE key mismatch found (NOT fixed, out of scope):** BE setting is `inventory.auto_approve_receipts`; the FE (`settings.component.ts`, `stock-receipts.component.ts`) reads `inventory.receipt_auto_approve`. **The FE reads a key that does not exist** → its "quick mode" badge may not reflect the real flag. Worth its own ticket.
- **EDGE CASE 2 — GRN + receipt = ONE approval.** `InventoryReceipt::isGrnLinked()` (soft-coupled `class_exists`, batch-resolved in the resource `collection()` to avoid N+1); the receipt engine is skipped entirely for a GRN's receipt; `submitApproval()` 422s on one; the resource reports `required:false`. Standalone receipt still demands its own. Both tested.
- **Proofs:** pending GRN → zero stock, **zero GR-IR**, no receipt created, `received_quantity === 0.0`, post 422; controlled mode → no draft receipt handed to the keeper either. No-workflow → `ApprovalLog::count()===0`, stock 50, `JournalEntry::count()===1` (exactly as today).
- Tests: 10 + 10 new green · Inventory 399/1 (known) · Purchases 385/11 (exactly the known 11) · Core approval 19 passed. **Zero new failures.** Catalog counts bumped in all THREE places (12→13 types, 10→12 wired; `delivery_note` remains the last `false`).
- FE: receipts commit **"استلام واعتماد" + green ✓ → "استلام/Receive"** (`pi-box`, contrast); GRN → «ترحيل/Post». **The heavyweight `approve()` body is untouched** — same re-fetch, same `resolveTrackingForReceipt()`, same simple-confirm-vs-`ReceiptApprovalDialogComponent` fork. A BE test posts split batches through the dialog's payload shape AFTER a governance approval and asserts both `InventoryReceiptItemBatch` rows.
- Judgment calls: GRN reject restores `QualityApproved` (not Draft) in quality mode, so a rejection doesn't silently discard a passed quality check. `PurchaseGrnStatus::canCancel()` gained `PendingApproval` (else a pending GRN would be uncancellable).
- ⚠️ **WP3 has NOT had its Fable [FIN] gate yet** — the implementer flagged this itself. Owed.
- 2026-07-14: 🔴 P1–P5 fix agent dispatched (the penny-gate hole + 4 others).

## ✅ P1–P5 FIXED (BE `de631e952` · FE `a8c0344c2`)
- **P1 [CRITICAL] CLOSED — the penny gate.** `InventoryAdjustment::getTotalAttribute()` now prices **DECREASE** lines at `max(abs(user total_cost), StockService::getIssueCost(...)['total_cost'])` — read-only WAC/FIFO, same call `InventoryIssue` already used. **INCREASE lines keep the user's cost** (own test: 10×7=70, not 10×WAC). Sum stays **gross absolute**.
  **PROOF TEST:** user types 5,000 × 0.01 → `items.sum(total_cost) === 50.0` but **`$adjustment->total === 500000.0`** (5,000 × WAC 100) → submit creates a **pending log at level 1** → commit **422** → `inventory_movements` 0 → balance still 100.0. Plus: user cost still wins when higher (max, not replace), and the same doc with **no workflow ⇒ `ApprovalLog::count() === 0`**.
- **P2 [HIGH] CLOSED** — a doc rejected from the inbox is no longer stranded: a Re-submit button (`pi-replay`) shows when `status==='pending_approval' && approval.required && approval.state==='none'`. Verified against `buildApprovalBlock()`: a rejected cycle reports `'none'`, an approved-but-unposted one reports `'approved'` — so it appears exactly on the rejected/empty case and **never** on an approved one (P3-safe).
- **P3 [MEDIUM] CLOSED in the SHARED layer** — new `DrivesApprovalWorkflow::canResubmitPendingApproval()` (= `!hasApprovalLogs || hasRejectedApprovalCycle`). **The implementer found the SAME hole in Receipt + GRN** (identical `!hasPendingApprovalLog` defeat) and fixed all four at once — house rule honoured.
- **P5 [LOW] CLOSED in the SHARED layer** — new `supersedeApprovalCycle()` called from `cancel()` on adjustment + issue (strict no-op with zero rows). Cancelling a pending doc now empties the approver's inbox (audit row kept).
- Tests: `InventoryApprovalWiringTest` **22 passed** (was 11) — all 4 sacred-invariant `ApprovalLog::count()===0` assertions still green. Receipt+GRN+Bypass+Catalog **39 passed**. Inventory **410 passed / 1 failed** (the known one). Zero new failures.

## 🔴 REMAINING — surfaced to owner, NOT done (do not claim otherwise)
1. **[LOUDEST] The adjustment VALUATION still trusts user cost.** P1 hardened only the *threshold*. `ApproveAdjustment` still **values the stock movement** at the user's `unit_cost` — so once approved, the penny write-off posts **5,000 units out of the warehouse at 50 EGP** in the books. The control now works (someone must sign); **the number in the ledger is wrong.** Pre-existing valuation bug, outside the approved scope. **Offered to the owner.**
2. **Inbox rot in 6 OTHER documents.** `PurchaseRequest/PurchaseOrder/PurchaseBill/SalesInvoice/SalesReturn/PurchaseGrn` all allow `canCancel()` from `PendingApproval` and none closes the cycle on cancel → the same forever-pending inbox rows. The shared `supersedeApprovalCycle()` helper now exists; each needs one line. Not done (WP boundary).
3. **P4 — the issue estimate/actual gap** (WAC/FIFO can move between submit and post; a doc authorized at level 1 could post at level-2 value). Scheduled follow-up.
4. **WP3 has not had its Fable [FIN] gate.** Owed.
- 2026-07-14: WP2 ✅ CLOSED (with fixes). WP4 🔄 started (Delivery Note).

## WP4 implemented (BE `2c7254c43` · FE `e7bb8a8f3`) — Fable [FIN] owed
- Guard in `confirm()` precedes `ConfirmDeliveryNote::execute()` (the sole caller of `decreaseStock()`). **`ship()`/`deliver()` NOT gated** (pure status writes) — test drives both after a gated confirm and asserts no new logs/movements.
- 🔴 **LATENT BUG WE WOULD HAVE INTRODUCED — caught and fixed.** `CancelDeliveryNote` computed `$wasConfirmed = status !== Draft`. Once `PendingApproval` exists, that predicate **matches it** → cancelling a PENDING note would have **increased stock that was never deducted** (phantom stock from nothing). Replaced with an explicit `[Confirmed, Shipped, Delivered]` whitelist + test.
- **`sales_delivery_notes` has NO monetary column at all** (only `shipping_cost`); DN items carry quantity only. So the engine would have seen **0 for every note** and slipped under every threshold. `getTotalAttribute()` derives from the order lines drawn down: `(orderItem.line_total + orderItem.tax_amount) × (dnQty / orderedQty)` — verified in `SalesCalculationService` (line_total is NET of discount, tax-exclusive) and `SalesOrder::recalculateTotals()`. `unit_price` deliberately NOT used (would over-value a discounted line and escalate to the wrong level). Test: 4-of-10 note on a 250 order → `total === 100.0`, and `min_amount 50` gates it.
- **BOTH setting modes tested:** `'delivery'` → confirm deducts → the approval PROTECTS STOCK. `'invoice'` → confirm deducts nothing → the approval authorises the CUSTOMER COMMITMENT (`delivered_quantity` stays 0 while pending, becomes 4.0 only after approval). Documented for the owner.
- Tests: 10 new green · Sales 366/7 (exactly the known 7) · Core approval 19. **Zero new failures.** Catalog: `wired` 12 → **13**, plus a new `where('wired', false)->toHaveCount(0)` — **no unwired type remains.**
- **Deviation flagged (accepted):** the inbox shows the DN's **number but no amount**. `attachDocumentReferences()` reads a REAL column via the query builder, so a virtual accessor cannot feed it — and the brief said "no migration". Map row uses `null` (identical to the already-shipped `purchase_request`). The THRESHOLD is correct and non-zero. Follow-up if the owner wants the amount visible: add `total decimal(15,3)` to `sales_delivery_notes`, stamp it from the same accessor, point the map's third element at it.
- Permissions: governance endpoints ride `sales.delivery_notes.confirm` (no `.approve` permission exists for DNs); submit rides `.create`. **WP6 owns this.**
- 2026-07-14: WP4 ✅ impl. WP5 🔄 started. Fable [FIN] dispatched for WP3+WP4+fixes.

## WP5 ✅ done (`aaa0d3588`)
- Config dropdown now driven by `GET /core/approval-workflows/document-types`; the 3 hardcoded maps + hand-built `buildOptions()` DELETED. **Filters `wired === false`** — the permanent guard that makes the "configurable but inert" trap impossible to recreate. Groups by the server's `module_label` (Inventory appears on its own). Re-fetches on language change (BE renders labels via `Accept-Language`).
- The silent `?? 'sales'` module fallback is GONE — if the catalogue fails to load the save is **refused** rather than filing the workflow under the wrong module.
- 🔴 **BIG FIND — the inbox was practically useless for 8 of 13 types.** Only 5 screens honoured `?viewId=`; the other **8 dropped the approver on a bare list with no idea which row to open.** All 8 patched (`handleApprovalDeepLink()` in `ngOnInit` → `getById` → open the existing detail dialog). **3 of them (`purchase_request`, `purchase_order`, `purchase_return`) were ALREADY mapped in `docTypeRoute` yet their screens ignored `viewId` — a pre-existing dead link**, fixed rather than reported (same 3-line patch).
- Inventory screens live under the **`/core/`** route group, not `/inventory/`.
- The config screen's duplicate pending tab no longer renders raw slugs (`grn`, `inventory adjustment`).
- No new i18n keys needed (WP2-4 added them); corrected `DOC_TYPE_HINT` which still said "(Sales/Purchases)".
- ⚠️ Deep links verified **statically only** (type-checked call sites; no FE test runner). **Owner click-through on /app is the remaining validation.**
- 2026-07-14: WP5 ✅. WP6 🔄 started (permissions — last WP).

## ✅ FABLE [FIN] GATE — WP3 + WP4 + P1-P5 fixes: **money is sound; ONE required fix**
Fable ran all 55 tests live against the tree: **616 assertions, 0 failures.**
### Verified sound (adversarially)
- **P1 penny gate genuinely CLOSED.** `getIssueCost` is **read-only on BOTH paths** (WAC reads StockBalance only; FIFO's `calculateFifoCost` iterates cost layers and never consumes them — the catastrophic "threshold read eats FIFO layers" case does not exist). Increase-line dilution impossible (gross-absolute). Edit-after-submit impossible (`update()` refuses `!isDraft()`). Zero-qty/fractional lines give no leverage. Variant/warehouse lookup handles null correctly and uses the same warehouse `ApproveAdjustment` posts against.
- **WP3 — no bypass.** GRN guard confirmed BEFORE `DB::transaction`, receipt creation, the fork, `ApproveReceipt` and `FinalizeApprovedGrn`. **`isGrnLinked` spoofing is impossible via API** — `inventory_receipt_id` is fillable but **no FormRequest validates it**, so it can't be injected; the only writer is the gated `approve()`. `auto_approve_receipts` provably cannot bypass. `total_cost` is re-stamped on EVERY items write, so "create cheap → edit expensive → submit stale" fails.
- **WP4 sound.** The derived DN money is right (net of discount, tax-inclusive, pro-rated). The "null order_id contributes 0" fallback is **unreachable** (the FormRequest requires `order_id` and `order_item_id`). The phantom-stock cancel fix is complete.
- **THE BUG CLASS I ASKED ABOUT — CLEAN.** Grep of `!== Status::Draft` across Inventory/Purchases/Sales → **zero remaining hits**; the DN one was the only reversal-deciding instance. All 29 `!isDraft()` hits are refuse-unless-draft guards, which with `PendingApproval` refuse MORE (safe direction). FE likewise clean.
- **The invariant is PROVEN for all five documents** (no workflow ⇒ `ApprovalLog::count()===0` + byte-identical stock/GL, including the auto-approve-on-create and straight-to-commit paths).
### 🔴 REQUIRED FIX (1 line + 1 test) — queued behind WP6
**`PurchaseGrnController::cancel()` never closes the approval cycle.** `PurchaseGrnStatus::canCancel()` now includes `PendingApproval` (this diff), but the P5 `supersedeApprovalCycle()` call was applied to adjustment (:315), issue (:411) and delivery note (:570) — **and the GRN was missed.** Failure: a clerk submits a 1.5M GRN, then cancels it before the approver acts → the pending `approval_logs` rows stay **active forever** in the approver's inbox for a document nobody can act on. No stock/GL exposure (the commit refuses Cancelled), but it is exactly the rot P5 shipped to kill, and it corrupts the audit trail. Fix: `$this->supersedeApprovalCycle($cancelled, ApprovalDocumentType::GRN);` after the cancel transaction + a test asserting `whereNull('superseded_at')->count() === 0`.
### Recommended, NOT blocking (recorded — do not claim done)
1. **P1 hardening:** if a product has **no StockBalance and no FIFO layers**, system cost resolves to **0** and the user's penny cost governs again (and `decreaseStock` allows negative). Also a degenerate WAC=0 with real qty on hand. Rare, low exploitability (no physical stock released), but worth flooring the decrease-line cost at the product's default/last-purchase cost.
2. **3 `ApproveReceipt` call sites BYPASS the receipt workflow** — `PostPurchaseBill.php:640` (autoApprove:true), `Production/ReceiveFinishedGoods`, `OpeningBalanceController`. **Not regressions** (nothing gated receipts before), but the CHANGELOG now promises "stock receipts can require approval" — **that promise is not universally true and must be caveated** in the KB/changelog. Conversely a recognition-at-receipt bill's draft receipt IS gated (it isn't GRN-linked) → possible double approval on money the bill workflow already signed; inconsistent with the GRN one-approval rule, though it errs toward MORE approval.
3. A pending standalone receipt has **no cancel/delete exit** (CancelReceipt needs Approved; destroy needs Draft) — rejection is the only way out; bricked if the approver role is deleted. Consider allowing cancel from PendingApproval (with the supersede call).
4. DN inbox shows number but no amount (needs virtual-amount support in `documentReferenceMap`).
5. **Salami-slicing** is inherent to per-document thresholds (5 DNs of 1 unit each each fall under `min_amount`) — true of every document in the engine, including invoices. Not a defect of this work; worth the owner knowing.

## WP6 ✅ done (BE `df158619b` · FE `844ffa506`) — the heart of the WP answered
**AUDIT (before): in ALL FIVE new documents the governance permission WAS the commit permission.** So every authorizer was, by construction, someone who could just post it himself — **the second signature was worth nothing.** Worse, it only widened: to grant someone the right to APPROVE a write-off you were forced to also grant them the right to MOVE STOCK. Delivery notes were starkest — governance rode `.confirm`, the button that ships the goods.
**CHOSEN: option (b′) — unify governance on the generic `core.approval-logs.act`** (the permission the My-Approvals inbox has always used). The doc-level endpoints become a strict alias of the inbox path: same permission, same approver-matrix check. **Zero new permissions, zero seeding, zero backfill.**
- Rejected (a) 5 new `*.approval.act` perms — redundant: the document is already scoped by the approval matrix (`canUserApprove()` 403s anyone who isn't the level's designated approver).
- Rejected (c) "document it" — this is a real separation-of-duties hole, not a limitation.
- **Safe because** the FE acts on approvals EXCLUSIVELY through the generic `core/approval-logs` endpoints; the doc-level governance endpoints have **no FE caller** (API parity, added 2 commits ago, never shipped) → **no live role loses any capability** → no backfill needed.
- **Deliberately did NOT auto-grant `core.approval-logs.act` to `.approve` holders** — that would re-merge the two duties and undo the entire fix. (The payroll lesson.)
- **Tests prove it:** a keeper holding ONLY the posting permission is **403'd on governance even when he IS the designated approver**; an approver holding ONLY `core.approval-logs.act` authorizes it and is then **403'd trying to post**.
- **Bonus hole closed (shared layer):** `rejectViaEngine()` no-ops when no level is pending, but all five controllers reset the doc to Draft regardless → **a fully-approved, unposted document could be knocked back to draft with the signatures silently discarded.** New `assertRejectable()` 422s a closed cycle.
- 🔴 **Opening balance: the buttons were DEAD, not just wrongly gated.** `OpeningBalanceController` has **no `approve` method at all** — `POST .../approve`, `PUT`, `DELETE` **all 404'd**. Not a wrong permission; three dead buttons. An opening balance IS an `InventoryReceipt` (`reference_type=opening`), so the calls now go to the receipts resource and carry the permissions its middleware actually enforces.
- **Dead gates: 0** (517 FE gates vs 1041 catalog; hyphen-safe AND prefix-aware — a trailing-dot gate like `clinic.payments.` is a live namespace gate that a naive `comm` reports as dead).
- Regression: **26 failed / 1621 passed = exactly the 26 known pre-existing.** Zero new.

## ✅ FINAL REQUIRED FIX (`671c67d60`) — Fable's one blocker, closed
`PurchaseGrnController::cancel()` now calls `supersedeApprovalCycle()`. Test: cancel a pending GRN → active rows drop to 0, the audit row survives (superseded, not deleted). Plus the invariant test: cancel with NO workflow touches zero rows. **13/13 GRN wiring tests green.**
**CHANGELOG corrected:** the receipt bullet promised approval universally. **Three flows create receipts that bypass the receipt workflow** (a supplier bill under receipt-recognition, production finished-goods, opening balances). Caveat added in BOTH languages rather than leaving a promise of protection that isn't universally there.

## 🔴 OPEN — surfaced to the owner, NOT done (never claim otherwise)
1. **[LOUDEST] Adjustment VALUATION still trusts user cost.** The THRESHOLD is now hardened (system cost), but `ApproveAdjustment` still **values the stock movement** at the user's `unit_cost` — so an approved penny write-off posts 5,000 units out at 50 EGP in the books. **The control works; the number in the ledger is wrong.** Pre-existing valuation bug, outside the approved scope. **Offered to the owner; awaiting his call.**
2. **P1 hardening:** a product with no StockBalance AND no FIFO layers resolves to system cost 0 → the user's penny governs again (and `decreaseStock` allows negative). Rare, low exploitability. Floor at the product's default/last-purchase cost.
3. **Three `ApproveReceipt` call sites bypass the receipt workflow** (now caveated in the CHANGELOG, not fixed).
4. **The older 8 documents still FUSE authorize+post** in one `approve()` on one permission — the 5 new ones are the correct shape. Natural next WP.
5. **The older 8 gate `submitApproval` on `.approve`** — meaning only an approver can submit for approval, which is backwards (the preparer should submit). The 5 new ones correctly use `.create`.
6. **Inbox rot in 6 other documents** (`cancel()` doesn't close the cycle) — shared helper now exists, each needs one line.
7. **P4 — issue estimate/actual gap** (WAC/FIFO can move between submit and post).
8. **Pending standalone receipt has no cancel/delete exit.**
9. **DN inbox shows number but no amount** (needs virtual-amount support in `documentReferenceMap`).
10. **Salami-slicing** is inherent to per-document thresholds (5 DNs of 1 unit each fall under `min_amount`) — true of every document in the engine including invoices. Owner should know.
11. **Deferred by decision:** Inventory Count (wiring it would double-approve), Transfer (engine has no quantity threshold), Opening Balance (no draft state).

## 🔴 FINAL CROSS-WP REVIEW — VERDICT: **BLOCK**. 2 CRITICAL bugs, ours, one violating our OWN invariant.
This is exactly what the final review exists for. The per-WP reviews could not see these — they are seams BETWEEN WPs.

### 🔴 CRITICAL 1 — the new `rejectApproval()` has NO document-status guard → on the NO-WORKFLOW path it is a bare "set status = Draft"
`rejectApproval()` = `assertRejectable()` → `hasApprovalLogs()`-guarded `rejectViaEngine()` → an **UNCONDITIONAL** `update(['status' => Draft])`. With **no workflow configured — the DEFAULT** — `assertRejectable()` returns immediately (empty cycle) and `hasApprovalLogs()` is false, so **the only surviving effect is the status write.** Anyone holding `core.approval-logs.act` (deliberately granted to approvers who hold NO posting rights) can reset **any** document — **including an already-POSTED one** — to Draft.
- **Adjustment:** posted → reject → draft → re-post → **stock moves TWICE**. Or cancel → `CancelAdjustment` only reverses `if (isApproved())` (now false) → **cancelled with its shrinkage permanently on the books, unreversed.**
- **Issue:** → second stock decrease **+ a second COGS journal entry**; and `isCancellable()` = `[Approved, PartiallyIssued]` → cancel **422s forever** → **the original issue can NEVER be reversed.**
- **Receipt:** stock added twice + duplicate serials; cancel 422s forever.
- **GRN:** → a **second InventoryReceipt**, a **second GR-IR journal entry**, double-counted `received_quantity`; `destroy()` will soft-delete a GRN that has posted stock and a JE.
- **Delivery note:** Confirmed → Draft → cancel **does not restore stock** (the phantom-stock class re-entered from the other side); `canConfirm()` allows Draft → **re-confirm → stock deducted twice.**
**THE PERVERSE ASYMMETRY:** a workflow-CONFIGURED document is accidentally SAFE (its fully-approved cycle makes `assertRejectable()` throw). **It is precisely the NO-WORKFLOW companies — the ones our invariant PROMISES are byte-for-byte untouched — that are exposed.** A direct violation of the invariant this entire feature rests on.

### 🔴 CRITICAL 2 — the GRN receipt exemption is UNCONDITIONAL → a configured Inventory-Receipt workflow gates NO purchase stock at all
The "one approval per money" rule exempts a GRN's receipt from the receipt workflow, assuming the GRN carries the approval. **But the GRN only carries one if a GRN workflow is configured, and nothing enforces that pairing.**
Configure a workflow for `inventory_receipt` ONLY → the catalogue says `wired: true` → the admin believes incoming stock is gated. Simple mode: the GRN's `assertApprovedForPost` is a no-op (no GRN workflow) → it calls `ApproveReceipt::execute()` **directly**, never touching the receipt controller → **stock + GR-IR JE posted with ZERO approval_logs.** Controlled mode: the keeper posts the linked receipt → `isGrnLinked()` → **the whole gate block is skipped** → same. **Every unit of purchased stock enters the books unapproved despite an explicitly configured control.**

### ⚠️ IMPORTANT 3 — TOCTOU: the gate is OUTSIDE the posting transaction and no document row is locked
Two concurrent `POST …/approve` both see `canPost() === true` → **stock moved twice, two COGS/GR-IR entries.** Approve/reject race: the keeper's gate reads an approved cycle, the approver rejects, the keeper commits → **stock posts on a document carrying a binding rejection.** Fix: `lockForUpdate()` the doc row INSIDE the transaction and re-assert.
**Related:** `PurchaseGrnController:732-734` documents a lock ordering ("ApproveReceipt write-locks the receipt…") that is **factually FALSE** — `ApproveReceipt` contains **no `lockForUpdate`**. A false invariant in a comment that a deadlock-avoidance argument rests on.

### MINOR
4. `InventoryReceiptController::cancel()` is the ONLY one of the five that doesn't `supersedeApprovalCycle()`.
5. `approveApproval()` is a **silent no-op on the auto path** — returns **200 OK** with nothing logged and the document unchanged; the FE renders success. A misleading audit surface.
6. `submitApproval()` sets `PendingApproval` even when the engine returns `auto_approved` (zero logs) → `pending_approval` doesn't imply anything is pending; and the doc becomes uneditable, making `rejectApproval` the ONLY way back — **which is why Critical 1's fix must be a STATUS GUARD, not endpoint removal.**

### Clean (hunted, nothing found)
- The `PendingApproval` predicate class OUTSIDE the cancel actions: **clean** — every cross-module consumer uses a positive whitelist, never a negated-draft predicate.
- Trait mutual consistency: the "current cycle" definition IS genuinely single-sourced and all helpers agree.
- `submitBeforeApproveIfBypassing` × `auto_approve_receipts`: correctly ordered; no zero-amount threshold slip.
- WP6's permission ROUTING is airtight — no governance action left on a commit permission. (The defect is what sits BEHIND the gate, not the gate.)

→ **Fix agent dispatched. This feature is NOT done and must NOT be reported as done until these land and the invariant tests are re-proven.**

## ✅ THE 2 CRITICALS + TOCTOU FIXED (`4b1005581`)
- **C1 CLOSED (shared layer):** new `assertDocumentRejectable()` — 422 `approval_document_not_pending_block_reject` unless the doc `isPendingApproval()`. Swapped into all five controllers. **A STATUS guard, not endpoint removal** (the no-workflow escape hatch depends on the endpoint existing — see MINOR 6). PLUS `consumeApprovalCycleOnPost()` called **inside** each posting transaction, so an approved cycle authorises exactly ONE commit (a doc later reset to Draft re-enters the engine with a FRESH cycle instead of riding old signatures).
  **PROOF (5/5):** posted GRN → reject = **422**, status stays `approved` → second post = **422** → stock 50 (once), JournalEntry 1, InventoryReceipt 1, `received_quantity` 50 — **and `ApprovalLog::count() === 0`** (the invariant, on the reject path). Same for adjustment (95, 1 movement), issue (80, no 2nd COGS, **and cancel STILL WORKS** — the permanent-422 trap is gone), receipt (10, cancel works), DN (196, **cancel still restores stock to 200**). Each paired with a "legitimate reject still works" test.
- **C2 CLOSED:** `receiptApprovalCarriedByGrn(companyId, isGrnLinked)` = `isGrnLinked && a GRN workflow EXISTS for the company`. Keyed on **workflow existence**, not on the GRN's cycle (the cycle is consumed at post, so a cycle-based test would have broken EDGE CASE 2). Receipt-only workflow ⇒ the receipt IS gated (the GRN now defers the receipt to the keeper instead of calling `ApproveReceipt` directly past every gate). Both workflows ⇒ still ONE approval on the GRN — **EDGE CASE 2 stayed green untouched.**
- **TOCTOU CLOSED:** `lockForUpdate()` on the document row **inside** the posting transaction + re-assert of `canPost()`/`canConfirm()`/`canApprove()` **and** `assertApprovedForPost()`, in all five actions. The actions now `use DrivesApprovalWorkflow` so the gate is the SAME code, not a copy.
  **And the false comment was made TRUE:** `PurchaseGrnController:732` claimed "ApproveReceipt write-locks the receipt" — it didn't. The implementer **added the lock the comment claimed** rather than deleting the comment.
- MINOR 4 (receipt cancel supersedes) and MINOR 5 (`approveApproval()` now 422s instead of a silent 200 on the auto path) fixed. MINOR 6 reported, not changed (changing it would remove the only route back to editable for no-workflow companies — and is exactly why C1 is a status guard).
- **Tests: approval suites 79 → 105, all green.** Full suite **1637 passed / 26 failed = exactly the known 26.** I re-verified the invariant myself: `ApprovalBypassGuardTest` + `InventoryApprovalWiringTest` = **45 passed**.

## ⚠️ NEW CONCERN I RAISED MYSELF — inverted lock ordering (deadlock) — UNDER REVIEW
The TOCTOU fix added locks. I traced them and found the two rows (GRN, InventoryReceipt) are locked in **opposite orders** across paths:
- `GRN::approve()` (simple): **GRN(:642) → RECEIPT(ApproveReceipt:61)**
- `Receipt::approve()` (controlled): **RECEIPT(:61) → GRN** (via `FinalizeGrnOnReceiptApproval:36-38`)
- `GRN::cancel()`: **RECEIPT(:785) → GRN(:788)** — and its own comment states the required order is "receipt first, then GRN — otherwise a concurrent keeper-approve deadlocks".
**My hypothesis (NOT yet confirmed):** unreachable, because in simple mode the receipt Path A locks is CREATED IN THE SAME TRANSACTION (uncontended), and in controlled mode Path A never calls ApproveReceipt at all (mutually exclusive by mode). **But C2's fix changed the mode routing** (a receipt-only workflow now makes a *simple-mode* GRN defer to the keeper) — which is exactly where a new interleaving could hide.
**I refuse to sign off a concurrency argument on a production ERP by hand-waving.** Focused lock-ordering review dispatched. If the deadlock is genuinely unreachable, the invariant that makes it so MUST be written into the code as a comment so the next person doesn't break it.

## 🔴 LOCK REVIEW — **MY HYPOTHESIS WAS WRONG. The deadlock is REAL and reachable.** VERDICT: BLOCK.
I raised the concern myself and guessed it was unreachable. **The review demolished that — correctly.** The deadlock does NOT run through the receipt row (my theory); it runs through an **unindexed `lockForUpdate()`** I never looked at.

### CRITICAL — reachable deadlock, INTRODUCED BY OUR COMMIT `4b1005581`
`FinalizeGrnOnReceiptApproval.php:36-39` locks GRNs via `where('inventory_receipt_id', …)->lockForUpdate()`. **`purchase_grns.inventory_receipt_id` HAS NO INDEX** (bare column, migration `…300001:36`; confirmed against the LIVE schema). Live `EXPLAIN`: **`type=ALL, key=NULL`** — a full table scan. Server is **MariaDB 10.11, REPEATABLE-READ**, where a `FOR UPDATE` full scan **X-locks EVERY row it scans**.
⇒ **Every `ApproveReceipt` in the entire ERP X-locks the whole `purchase_grns` table** for the rest of its transaction. (The listener's docblock claims "no-op for every other receipt" — true semantically, **FALSE at the lock level**.)
**The cycle** (two ordinary simple-mode GRN approvals — nothing exotic): T1 approves GRN#9, T2 approves GRN#1. Both take the row lock we added at `PurchaseGrnController:642`. T1's listener scan starts at PK 1 → waits on T2's grn#1. T2's scan reaches PK 9 → waits on T1's grn#9. **InnoDB 1213, one transaction rolled back MID-POST.**
**We caused it:** pre-commit, `approve()` held NO GRN lock; the GRN's X-lock came from `FinalizeApprovedGrn`'s `update()` — i.e. **AFTER** the listener scan. Nothing held a `purchase_grns` row lock across the scan, so no cycle existed. **Moving the lock to the top of the transaction closed the cycle.**
And **any** ApproveReceipt collides (opening balances, manual receipts, the keeper's post, direct-bill receipts) with any holder of a `purchase_grns` row lock.

### HIGH — the reject endpoints re-assert on an UNLOCKED model → concurrent double-post
`InventoryReceiptController:565-576` has **no transaction and no lock**: the guard is evaluated on a stale in-memory model, then a blind `update(['status' => Draft])`. Race: the rejector's guard passes → the poster locks, **posts the stock**, sets Approved, supersedes the cycle, commits → the rejector's UPDATE (which merely *waited* on the lock) lands → **a posted, stock-moved receipt is back to Draft** → re-approve → `canPost()` true, `assertApprovedForPost()` sees an EMPTY cycle (superseded) → **returns silently** → **stock posted a SECOND time.**
**This is exactly the CRITICAL-1 scenario we "closed" — closed against a SEQUENTIAL attacker, still open against a CONCURRENT one.** Same in Issue/Adjustment/DeliveryNote/GRN reject.

### HIGH — throughput: every ApproveReceipt table-locks `purchase_grns` (independent of the deadlock).
### MEDIUM (pre-existing) — unordered lock loops: `PostGrnGrniAccrual:76-77` (PO items in payload order) and `ApproveIssue:86` (stock balances in item order) → order-dependent deadlocks.
### Confirmed by the review (my Q2 instinct was INVERTED):
C2's new mode-routing branch is the **SAFEST** — it never calls `ApproveReceipt`, so it can't be either side of the cycle. **The hole is the UNTOUCHED default branch** (simple/no-workflow — the path most companies run), because that is the one holding the GRN lock across a nested `ApproveReceipt`.
Also confirmed: the listener runs **INSIDE** ApproveReceipt's transaction (plain `event()`, sync listener, no `afterCommit`), so it holds the receipt lock while taking GRN locks. And the re-assert IS correctly evaluated on the locked row in all five actions — the defect is that **reject never takes the lock at all**.

→ **Fix dispatched: index `purchase_grns.inventory_receipt_id` (unique if no dupes) · listener locks BY PK · lock+re-read in all reject endpoints · write the "GRN→RECEIPT is only safe because the receipt is created in-transaction" invariant into the code · ksort the lock loops.**
**LESSON: I raised the right question and then talked myself into the wrong answer. The value was in refusing to sign off on my own analysis.**

## ✅ DEADLOCK + REJECT RACE CLOSED (`70622fdf8`) — the feature is now DONE
- **The index: UNIQUE** on `purchase_grns.inventory_receipt_id`. Duplicate check run on dev first (0 rows). Verified live: `SHOW INDEX` → `Non_unique=0`. `EXPLAIN` on the listener's predicate went from **`type=ALL, key=NULL`** (full table scan → X-locks every row under REPEATABLE-READ) to a **`const` unique lookup**. **The deadlock is killed at the root.**
- **Listener locks BY PK now:** a plain non-locking `->value('id')` read, then `whereKey($id)->lockForUpdate()`, then a **re-check of `status !== PendingReceipt` under that lock** (the unlocked read is itself a TOCTOU window). The false "no-op for every other receipt" docblock is corrected — it was a no-op *semantically* but **not at the lock level**.
- **The invariant is now WRITTEN INTO THE CODE** at `PurchaseGrnController::approve()`'s lock: GRN→RECEIPT only survives because the receipt is created *inside this same transaction* (uncontended); **never lock a pre-existing receipt from this transaction**; and the listener **must** lock by PK — with the full T1/T2 deadlock cycle spelled out so the next person can't unknowingly reopen it.
- **Reject race closed in all five controllers:** `rejectApproval` now runs inside `DB::transaction` → `lockForUpdate()` → re-read → guard on the **locked** row.
- **The implementer CORRECTED MY BRIEF (important):** I located the PO-item lock-ordering bug only in `PostGrnGrniAccrual:76-77`. **Incomplete** — `FinalizeApprovedGrn::updatePoReceivedQuantities()` runs FIRST in the same transaction and is the actual first-toucher of those rows, so **it** decides the lock order; ksorting only the accrual would have fixed nothing. Both fixed (+ it closed a lost-update read-modify-write there). It also extended the stock-balance ordering fix to `ApproveReceipt` as well as `ApproveIssue` — they lock the same rows, so a single total order across both is what actually removes the cycle.
- **HONEST ABOUT WHAT THE TESTS CANNOT PROVE (the implementer volunteered this):** the test driver is **SQLite, which compiles `lockForUpdate()` to nothing** — so no test proves InnoDB actually serialises the two transactions, and the *absence* of a deadlock is not asserted anywhere. What IS proven is that the **mechanism changed**: the index is unique; no query in the keeper-approve path takes a lock through the `inventory_receipt_id` predicate; the listener's resolve is a lock-incapable `select "id"` followed by `where id = ?`. A new test also proves the reject race: a post committed at the exact instant the controller takes its unlocked read (via `DB::listen`) is now **seen** → reject returns **422** and the posted receipt **stays Approved** (the old code blind-wrote Draft).
- Note: a unique index on a soft-deleting table means a soft-deleted GRN keeps its receipt id reserved. Correct today (one receipt per GRN, created in the GRN's own approve transaction); if a future flow ever re-points a receipt it must null the old GRN's column.
- **Tests: 93 approval tests passed / 0 failed.** Inventory+Purchases **817 passed / 12 failed**, Sales **369 / 7** — all 19 inside the known-26 list. **Zero new failures.** I re-verified the invariant myself: **60 passed**. Index confirmed live. `/app` deployed.

## 🏁 STATUS: DONE. All 6 WPs + 5 review-fix rounds. Awaiting owner `/fullpush`.
