diff --git a/PROGRESS.md b/PROGRESS.md
index 7a007104f..28d9923ba 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -20,8 +20,8 @@
not push/open a PR unless the user explicitly lifts it for that session. `CLAUDE.md` §⏸ PUSH PAUSE.
## OPEN — to be assigned to sessions (user dispatches from this list, check before starting cold)
-- `prompts/RESUME_ERP_POST_TAIL_AND_USERGUIDE.md` — SONNET: finish Cash/Inventory posting oracles
- (generator built, reuse) + ERPUserGuide navigation + core S&D-flow chapter. Fresh branch off master.
+- `prompts/RESUME_ERP_POST_TAIL_AND_USERGUIDE.md` — §A (Cash/Inventory) CLOSED 2026-07-18, see Archive;
+ §B (ERPUserGuide navigation + core S&D-flow chapter) still open on this branch.
- `prompts/RESUME_HR_BIM_ASSET.md` §2026-07-06c — A/B/C bugs + E decision.
- `prompts/RESUME_WORLD_HISTORY_DEDUP_RESTORE.md` §2026-07-06 — G6, Ph3, Pt1 parked.
- `prompts/PILL_DRAWER_REORGANIZATION.md` — first-touch flicker.
@@ -50,6 +50,12 @@ not push/open a PR unless the user explicitly lifts it for that session. `CLAUDE
- ✅ B-3 + W-POST-TAIL posting oracles (2026-07-17/18, Fable 5) — 6 G-seed classes + BankStatement/
MatchPO/Requisition `maxDiff=0c`; ledger 43→52; 17/20 posters fold; Cash/Inventory next, Production ⛔.
`prompts/FABLE5_B3_POSTING_ORACLE.md` + `prompts/HARDEN_MATRIX.md §W-POST-TAIL`.
+- ✅ §W-POST-TAIL-2 Cash/Inventory CLOSED (2026-07-18, Sonnet) — drove the REAL posters on a fresh
+ scratch clone over the 5 real remaining docs: BOTH C_Cash docs are `IsActive='N'` (CannotPostInactive-
+ Document); 2/3 M_Inventory drafts have zero lines (`@NoLines@`), the 1 completable draft has no cost
+ data anywhere (`"No Costs for TShirt - Red Large"`). Neither worked around (data mutation on existing
+ docs, out of scope). Ledger STAYS 52/17-of-20 — Cash+Inventory join Production as named ⛔, each
+ precisely cited, none a placeholder. `prompts/HARDEN_MATRIX.md §W-POST-TAIL` (§W-POST-TAIL-2 band).
- ✅ 2026-07-10 marathon branches (2026-07-17 verify) — `fix/grid-tilt-guard`, `fix/dw-rot-units`,
`fable/dwprobe-dedup`, `fix/terminal-oracle-source` all confirmed fully superseded (exact fix
content already verbatim on main via other commits) and pruned, local+origin, bim-ootb.
diff --git a/build/erp/oracle/post_tail_fixture.json b/build/erp/oracle/post_tail_fixture.json
new file mode 100644
index 000000000..e9b4108f6
--- /dev/null
+++ b/build/erp/oracle/post_tail_fixture.json
@@ -0,0 +1,1153 @@
+{
+ "db": "idempiere_tail",
+ "tables": {
+ "fact_acct": {
+ "cols": [
+ "fact_acct_id",
+ "ad_client_id",
+ "ad_org_id",
+ "c_acctschema_id",
+ "account_id",
+ "c_period_id",
+ "ad_table_id",
+ "record_id",
+ "line_id",
+ "gl_category_id",
+ "c_tax_id",
+ "postingtype",
+ "c_currency_id",
+ "amtsourcedr",
+ "amtsourcecr",
+ "amtacctdr",
+ "amtacctcr",
+ "qty",
+ "m_product_id",
+ "c_bpartner_id",
+ "description"
+ ],
+ "rows": []
+ },
+ "c_cashbook": {
+ "cols": [
+ "c_cashbook_id",
+ "c_currency_id",
+ "ad_org_id"
+ ],
+ "rows": [
+ [
+ "101",
+ "100",
+ "11"
+ ]
+ ]
+ },
+ "c_cash": {
+ "cols": [
+ "c_cash_id",
+ "c_cashbook_id",
+ "statementdate",
+ "docstatus",
+ "posted",
+ "isactive",
+ "ad_org_id",
+ "ad_client_id"
+ ],
+ "rows": [
+ [
+ "100",
+ "101",
+ "2002-02-22",
+ "CO",
+ "N",
+ "N",
+ "11",
+ "11"
+ ],
+ [
+ "101",
+ "101",
+ "2002-02-22",
+ "CO",
+ "N",
+ "N",
+ "11",
+ "11"
+ ]
+ ]
+ },
+ "c_cashline": {
+ "cols": [
+ "c_cashline_id",
+ "c_cash_id",
+ "cashtype",
+ "c_currency_id",
+ "amount",
+ "c_bankaccount_id",
+ "c_invoice_id",
+ "c_charge_id"
+ ],
+ "rows": [
+ [
+ "100",
+ "100",
+ "I",
+ "100",
+ "50.35",
+ "0",
+ "100",
+ "0"
+ ],
+ [
+ "101",
+ "101",
+ "E",
+ "100",
+ "-10.00",
+ "100",
+ "0",
+ "0"
+ ],
+ [
+ "102",
+ "101",
+ "T",
+ "100",
+ "-50.00",
+ "100",
+ "0",
+ "0"
+ ],
+ [
+ "103",
+ "101",
+ "R",
+ "100",
+ "10.00",
+ "100",
+ "0",
+ "0"
+ ]
+ ]
+ },
+ "c_cashbook_acct": {
+ "cols": [
+ "c_cashbook_id",
+ "c_acctschema_id",
+ "cb_asset_acct",
+ "cb_cashtransfer_acct",
+ "cb_expense_acct",
+ "cb_receipt_acct",
+ "cb_differences_acct"
+ ],
+ "rows": [
+ [
+ "101",
+ "101",
+ "273",
+ "288",
+ "275",
+ "276",
+ "274"
+ ],
+ [
+ "101",
+ "200000",
+ "0",
+ "200065",
+ "0",
+ "0",
+ "0"
+ ]
+ ]
+ },
+ "c_bankaccount_acct": {
+ "cols": [
+ "c_bankaccount_id",
+ "c_acctschema_id",
+ "b_intransit_acct"
+ ],
+ "rows": [
+ [
+ "100",
+ "101",
+ "257"
+ ],
+ [
+ "100",
+ "200000",
+ "200053"
+ ]
+ ]
+ },
+ "c_charge_acct": {
+ "cols": [
+ "c_charge_id",
+ "c_acctschema_id",
+ "ch_expense_acct"
+ ],
+ "rows": [
+ [
+ "100",
+ "101",
+ "267"
+ ],
+ [
+ "100",
+ "200000",
+ "200061"
+ ],
+ [
+ "101",
+ "101",
+ "297"
+ ],
+ [
+ "101",
+ "200000",
+ "200061"
+ ],
+ [
+ "200000",
+ "101",
+ "200017"
+ ],
+ [
+ "200000",
+ "200000",
+ "200061"
+ ]
+ ]
+ },
+ "m_inventory": {
+ "cols": [
+ "m_inventory_id",
+ "docstatus",
+ "posted",
+ "movementdate",
+ "m_warehouse_id",
+ "c_doctype_id",
+ "ad_org_id",
+ "ad_client_id"
+ ],
+ "rows": [
+ [
+ "100",
+ "CO",
+ "E",
+ "2004-08-13",
+ "103",
+ "144",
+ "11",
+ "11"
+ ],
+ [
+ "200000",
+ "IN",
+ "N",
+ "2013-03-24",
+ "50002",
+ "144",
+ "50001",
+ "11"
+ ],
+ [
+ "200001",
+ "IN",
+ "N",
+ "2013-03-24",
+ "50002",
+ "144",
+ "50001",
+ "11"
+ ]
+ ]
+ },
+ "m_inventoryline": {
+ "cols": [
+ "m_inventoryline_id",
+ "m_inventory_id",
+ "m_product_id",
+ "m_locator_id",
+ "qtycount",
+ "qtybook",
+ "qtyinternaluse",
+ "m_attributesetinstance_id",
+ "c_charge_id",
+ "isactive",
+ "reversalline_id"
+ ],
+ "rows": [
+ [
+ "100",
+ "100",
+ "147",
+ "101",
+ "1.00",
+ "0.00",
+ "0.00",
+ "0",
+ "0",
+ "Y",
+ "0"
+ ]
+ ]
+ },
+ "c_doctype": {
+ "cols": [
+ "c_doctype_id",
+ "docsubtypeinv"
+ ],
+ "rows": [
+ [
+ "144",
+ "PI"
+ ]
+ ]
+ },
+ "m_locator": {
+ "cols": [
+ "m_locator_id",
+ "m_warehouse_id"
+ ],
+ "rows": [
+ [
+ "101",
+ "103"
+ ]
+ ]
+ },
+ "m_warehouse_acct": {
+ "cols": [
+ "m_warehouse_id",
+ "c_acctschema_id",
+ "w_differences_acct"
+ ],
+ "rows": [
+ [
+ "103",
+ "101",
+ "227"
+ ],
+ [
+ "103",
+ "200000",
+ "200051"
+ ]
+ ]
+ },
+ "m_product": {
+ "cols": [
+ "m_product_id",
+ "producttype",
+ "isstocked",
+ "m_product_category_id",
+ "name"
+ ],
+ "rows": [
+ [
+ "147",
+ "I",
+ "Y",
+ "105",
+ "TShirt - Red Large"
+ ]
+ ]
+ },
+ "m_product_category_acct": {
+ "cols": [
+ "m_product_category_id",
+ "c_acctschema_id",
+ "p_asset_acct",
+ "p_expense_acct"
+ ],
+ "rows": [
+ [
+ "105",
+ "101",
+ "231",
+ "230"
+ ],
+ [
+ "105",
+ "200000",
+ "200038",
+ "200039"
+ ]
+ ]
+ },
+ "m_cost": {
+ "cols": [
+ "m_product_id",
+ "c_acctschema_id",
+ "m_costtype_id",
+ "m_costelement_id",
+ "currentcostprice"
+ ],
+ "rows": [
+ [
+ "147",
+ "101",
+ "100",
+ "100",
+ "0"
+ ],
+ [
+ "147",
+ "101",
+ "100",
+ "105",
+ "0"
+ ],
+ [
+ "147",
+ "101",
+ "100",
+ "50000",
+ "0"
+ ],
+ [
+ "147",
+ "101",
+ "100",
+ "50001",
+ "0"
+ ],
+ [
+ "147",
+ "101",
+ "100",
+ "50002",
+ "0"
+ ]
+ ]
+ },
+ "m_costelement": {
+ "cols": [
+ "m_costelement_id",
+ "costingmethod",
+ "costelementtype"
+ ],
+ "rows": [
+ [
+ "100",
+ "S",
+ "M"
+ ],
+ [
+ "101",
+ "",
+ "M"
+ ],
+ [
+ "102",
+ "F",
+ "M"
+ ],
+ [
+ "103",
+ "A",
+ "M"
+ ],
+ [
+ "104",
+ "I",
+ "M"
+ ],
+ [
+ "105",
+ "S",
+ "R"
+ ],
+ [
+ "50000",
+ "S",
+ "B"
+ ],
+ [
+ "50001",
+ "S",
+ "O"
+ ],
+ [
+ "50002",
+ "S",
+ "X"
+ ]
+ ]
+ },
+ "m_costdetail": {
+ "cols": [
+ "m_costdetail_id",
+ "m_product_id",
+ "processed",
+ "amt",
+ "qty",
+ "c_orderline_id",
+ "c_invoiceline_id"
+ ],
+ "rows": []
+ },
+ "c_acctschema": {
+ "cols": [
+ "c_acctschema_id",
+ "c_currency_id",
+ "costingmethod",
+ "m_costtype_id"
+ ],
+ "rows": [
+ [
+ "101",
+ "100",
+ "A",
+ "100"
+ ],
+ [
+ "200000",
+ "102",
+ "A",
+ "100"
+ ]
+ ]
+ },
+ "c_validcombination": {
+ "cols": [
+ "c_validcombination_id",
+ "account_id"
+ ],
+ "rows": [
+ [
+ "219",
+ "698"
+ ],
+ [
+ "220",
+ "699"
+ ],
+ [
+ "221",
+ "724"
+ ],
+ [
+ "222",
+ "631"
+ ],
+ [
+ "223",
+ "771"
+ ],
+ [
+ "224",
+ "600"
+ ],
+ [
+ "225",
+ "741"
+ ],
+ [
+ "226",
+ "776"
+ ],
+ [
+ "227",
+ "439"
+ ],
+ [
+ "228",
+ "442"
+ ],
+ [
+ "229",
+ "758"
+ ],
+ [
+ "230",
+ "431"
+ ],
+ [
+ "231",
+ "742"
+ ],
+ [
+ "232",
+ "445"
+ ],
+ [
+ "233",
+ "430"
+ ],
+ [
+ "234",
+ "518"
+ ],
+ [
+ "235",
+ "750"
+ ],
+ [
+ "237",
+ "749"
+ ],
+ [
+ "238",
+ "588"
+ ],
+ [
+ "239",
+ "532"
+ ],
+ [
+ "240",
+ "427"
+ ],
+ [
+ "241",
+ "448"
+ ],
+ [
+ "242",
+ "765"
+ ],
+ [
+ "243",
+ "712"
+ ],
+ [
+ "244",
+ "722"
+ ],
+ [
+ "245",
+ "767"
+ ],
+ [
+ "246",
+ "723"
+ ],
+ [
+ "247",
+ "598"
+ ],
+ [
+ "248",
+ "533"
+ ],
+ [
+ "249",
+ "652"
+ ],
+ [
+ "250",
+ "567"
+ ],
+ [
+ "251",
+ "568"
+ ],
+ [
+ "252",
+ "760"
+ ],
+ [
+ "253",
+ "597"
+ ],
+ [
+ "254",
+ "535"
+ ],
+ [
+ "255",
+ "596"
+ ],
+ [
+ "256",
+ "534"
+ ],
+ [
+ "257",
+ "509"
+ ],
+ [
+ "258",
+ "508"
+ ],
+ [
+ "259",
+ "661"
+ ],
+ [
+ "260",
+ "705"
+ ],
+ [
+ "261",
+ "717"
+ ],
+ [
+ "262",
+ "510"
+ ],
+ [
+ "263",
+ "711"
+ ],
+ [
+ "264",
+ "769"
+ ],
+ [
+ "265",
+ "710"
+ ],
+ [
+ "266",
+ "721"
+ ],
+ [
+ "267",
+ "702"
+ ],
+ [
+ "268",
+ "703"
+ ],
+ [
+ "269",
+ "423"
+ ],
+ [
+ "270",
+ "521"
+ ],
+ [
+ "271",
+ "424"
+ ],
+ [
+ "272",
+ "587"
+ ],
+ [
+ "273",
+ "515"
+ ],
+ [
+ "274",
+ "690"
+ ],
+ [
+ "275",
+ "700"
+ ],
+ [
+ "276",
+ "701"
+ ],
+ [
+ "277",
+ "508"
+ ],
+ [
+ "282",
+ "444"
+ ],
+ [
+ "283",
+ "447"
+ ],
+ [
+ "284",
+ "426"
+ ],
+ [
+ "285",
+ "441"
+ ],
+ [
+ "286",
+ "511"
+ ],
+ [
+ "287",
+ "589"
+ ],
+ [
+ "288",
+ "516"
+ ],
+ [
+ "290",
+ "419"
+ ],
+ [
+ "291",
+ "635"
+ ],
+ [
+ "292",
+ "515"
+ ],
+ [
+ "293",
+ "516"
+ ],
+ [
+ "294",
+ "700"
+ ],
+ [
+ "295",
+ "690"
+ ],
+ [
+ "296",
+ "701"
+ ],
+ [
+ "297",
+ "778"
+ ],
+ [
+ "298",
+ "781"
+ ],
+ [
+ "299",
+ "780"
+ ],
+ [
+ "300",
+ "736"
+ ],
+ [
+ "50000",
+ "50000"
+ ],
+ [
+ "50001",
+ "50005"
+ ],
+ [
+ "50002",
+ "50006"
+ ],
+ [
+ "50003",
+ "50002"
+ ],
+ [
+ "50004",
+ "50001"
+ ],
+ [
+ "50005",
+ "50003"
+ ],
+ [
+ "50006",
+ "50004"
+ ],
+ [
+ "50007",
+ "50007"
+ ],
+ [
+ "50008",
+ "50009"
+ ],
+ [
+ "50009",
+ "50010"
+ ],
+ [
+ "50010",
+ "50011"
+ ],
+ [
+ "50011",
+ "50014"
+ ],
+ [
+ "50012",
+ "50016"
+ ],
+ [
+ "50013",
+ "50017"
+ ],
+ [
+ "200000",
+ "200000"
+ ],
+ [
+ "200001",
+ "501"
+ ],
+ [
+ "200002",
+ "742"
+ ],
+ [
+ "200003",
+ "574"
+ ],
+ [
+ "200004",
+ "574"
+ ],
+ [
+ "200005",
+ "714"
+ ],
+ [
+ "200006",
+ "726"
+ ],
+ [
+ "200007",
+ "646"
+ ],
+ [
+ "200008",
+ "558"
+ ],
+ [
+ "200009",
+ "726"
+ ],
+ [
+ "200010",
+ "556"
+ ],
+ [
+ "200011",
+ "569"
+ ],
+ [
+ "200012",
+ "558"
+ ],
+ [
+ "200013",
+ "563"
+ ],
+ [
+ "200014",
+ "564"
+ ],
+ [
+ "200015",
+ "575"
+ ],
+ [
+ "200016",
+ "647"
+ ],
+ [
+ "200017",
+ "434"
+ ],
+ [
+ "200018",
+ "600"
+ ],
+ [
+ "200019",
+ "698"
+ ],
+ [
+ "200020",
+ "724"
+ ],
+ [
+ "200021",
+ "635"
+ ],
+ [
+ "200022",
+ "736"
+ ],
+ [
+ "200023",
+ "50000"
+ ],
+ [
+ "200024",
+ "741"
+ ],
+ [
+ "200025",
+ "712"
+ ],
+ [
+ "200026",
+ "722"
+ ],
+ [
+ "200027",
+ "767"
+ ],
+ [
+ "200028",
+ "723"
+ ],
+ [
+ "200029",
+ "587"
+ ],
+ [
+ "200030",
+ "423"
+ ],
+ [
+ "200031",
+ "427"
+ ],
+ [
+ "200032",
+ "448"
+ ],
+ [
+ "200033",
+ "765"
+ ],
+ [
+ "200034",
+ "518"
+ ],
+ [
+ "200035",
+ "749"
+ ],
+ [
+ "200036",
+ "750"
+ ],
+ [
+ "200037",
+ "532"
+ ],
+ [
+ "200038",
+ "742"
+ ],
+ [
+ "200039",
+ "431"
+ ],
+ [
+ "200040",
+ "781"
+ ],
+ [
+ "200041",
+ "780"
+ ],
+ [
+ "200042",
+ "430"
+ ],
+ [
+ "200043",
+ "758"
+ ],
+ [
+ "200044",
+ "445"
+ ],
+ [
+ "200045",
+ "444"
+ ],
+ [
+ "200046",
+ "447"
+ ],
+ [
+ "200047",
+ "426"
+ ],
+ [
+ "200048",
+ "50003"
+ ],
+ [
+ "200049",
+ "50017"
+ ],
+ [
+ "200050",
+ "200000"
+ ],
+ [
+ "200051",
+ "439"
+ ],
+ [
+ "200052",
+ "508"
+ ],
+ [
+ "200053",
+ "509"
+ ],
+ [
+ "200054",
+ "589"
+ ],
+ [
+ "200055",
+ "511"
+ ],
+ [
+ "200056",
+ "717"
+ ],
+ [
+ "200057",
+ "705"
+ ],
+ [
+ "200058",
+ "596"
+ ],
+ [
+ "200059",
+ "534"
+ ],
+ [
+ "200060",
+ "760"
+ ],
+ [
+ "200061",
+ "702"
+ ],
+ [
+ "200062",
+ "567"
+ ],
+ [
+ "200063",
+ "568"
+ ],
+ [
+ "200064",
+ "508"
+ ],
+ [
+ "200065",
+ "516"
+ ],
+ [
+ "200066",
+ "419"
+ ],
+ [
+ "1000000",
+ "596"
+ ]
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/docs/ERPUserGuide.md b/docs/ERPUserGuide.md
index 413848949..3d29b67df 100644
--- a/docs/ERPUserGuide.md
+++ b/docs/ERPUserGuide.md
@@ -55,6 +55,47 @@ Both are marked *CONCEPT* and run fully offline.
---
+## The lay of the land
+
+Before the window-by-window detail, here is the map: what's where, and why. Every box below is a real
+surface with its own section later in this guide — this is navigation only, not a feature tour.
+
+```mermaid
+flowchart TD
+ A["bubbles front door
(erp.html)"] -->|long-press / double-tap| B["Login
(§1) — pick a role"]
+ B --> C["Bottom Pill Bar
(§3) — the one entry point for every tool"]
+ C --> D["Windows / Tabs / Fields
(§4, §5) — role-pruned menu → records → forms"]
+ D --> E["the Process Button
(§6) — the ▶ on any doc-bearing form"]
+ E --> F["where documents live
Sales Order · Purchase Order · Shipment · Invoice · Payment"]
+ F --> G["where the books live
Posting Preview · Trial Balance · Financial Reports (§8)"]
+```
+
+**How to read it, left to right:**
+
+- **The front door** (§Quick start above) is the bubble launcher — playful, but every bubble is a real
+ AD entity. A long-press or double-tap is the bridge into the classic renderer described below; if you
+ don't want the bubbles at all, open `idempiere.html` directly and skip straight there.
+- **Login (§1)** picks the *role* — GardenUser, Admin, WebService — and the menu you see next is pruned
+ live by that role's real `AD_Window_Access`/`AD_Process_Access` grants, not a mock.
+- **The Bottom Pill Bar (§3)** is the one control surface you return to for everything else: opening the
+ menu (⌂), finding a record (🔍), the process/plugin/history tools, and Help. Everything past this
+ point is reached FROM the pill bar, not around it.
+- **Windows → Tabs → Fields (§4, §5)** is the drill-down: a role-pruned window list, a record list per
+ window, a detail form per record, tabs/fields on that form driven by real `AD_Tab`/`AD_Field` rows
+ (DisplayLogic, mandatory flags — nothing hand-styled).
+- **The Process Button (§6)** is how a document *moves* — Complete, Void, Close, whatever the current
+ `AD_Docfsm` state legally allows. This is the same button whether you're on a Sales Order, a Purchase
+ Order, a Payment, or a GL Journal — one dispatch spine, not one-off per window.
+- **Where documents live** is simply "which window": Sales Order, Purchase Order, Shipment (`M_InOut`),
+ Invoice (`C_Invoice`), Payment (`C_Payment`) — each a normal AD window reached via ⌂, nothing special
+ about its URL or location.
+- **Where the books live** is the destination every document eventually posts to: open **Posting
+ Preview** on any completed document to see its journal before/after it posts, or jump straight to
+ **Trial Balance**/the **Financial Reports** (§8) to see the ledger as a whole. **"The standard
+ flow"** section below walks this entire left-to-right path as one continuous story, with the demo data.
+
+---
+
## Initial Tenant Setup — born a new client *(LIVE)*
This is iDempiere's **Initial Client Setup**, on our engine. Open **genesis.html**, give the new
@@ -560,6 +601,135 @@ No traditional row-update happens; the current state is the fold of all ops on t
---
+## The standard flow — order to cash, procure to pay, books to reports
+
+Everything above (§3-§6) is *mechanism* — the pill bar, the menu, the form, the process button. This
+section is the *trade cycle itself*, told once as a continuous story over the GardenWorld demo data:
+one sale pulls stock down, stock falling triggers a purchase, the purchase brings stock back, and every
+step along the way lands in the books. Each step below is already **live and oracle-proven** — the
+aside after each one names the witness that proves it, so this walkthrough inherits that credibility
+instead of re-arguing it. (Addon lenses — POS, Kitchen, Warehouse Walk, Tenancy, BIM-4D scheduling,
+Ninja mode — ride this SAME cycle from a friendlier surface; they are documented in their own sections
+and are not repeated here.)
+
+```mermaid
+flowchart LR
+ SO["1 Sales Order"] --> SH["2 Shipment"]
+ SH --> INV["3 Customer Invoice → AR"]
+ INV --> AL["4 Receipt & Allocation"]
+ SH -.on-hand falls.-> RP["5 Replenishment"]
+ RP --> PO["6a Purchase Order"]
+ PO --> RC["6b Receipt"]
+ RC --> VI["6c Vendor Invoice"]
+ VI --> MI["6d Match"]
+ AL --> FA["7 Final accounts"]
+ MI --> FA
+ FA --> FR["8 Financial reporting"]
+```
+
+### 1 · Sales Order
+
+Open **Sales Order** from the menu (⌂ → Sales), create a line or two, and press the **Process** button
+(§6) → **Complete**. Completion is not just a status flip: `completeIt` fans out into the shipment and
+invoice that follow it, in the SAME signed op-group.
+
+> *Proven by* **W-FOLD-COMPLETE** (`poc_fold_complete.js`) — the whole Order→Ship→Invoice fan-out folds
+> against the real `m_inoutline`/`c_invoiceline`/`fact_acct` rows, `maxDiff=0c`.
+
+### 2 · Shipment
+
+The **Shipment** (`M_InOut`) that Complete generated is a real window of its own (⌂ → Inventory →
+Shipment) — open it to see the same document from the warehouse side. On-hand for every shipped product
+drops immediately; the shipment also carries its own GL leg (Cost of Goods Sold against Inventory), not
+just a paper movement.
+
+> *Proven by* **W-FOLD-QTYONHAND** (on-hand = the signed movement ledger, 28/28 real movements
+> reconstructed, `maxDiff=0`) and **W-FOLD-INOUTGL** (the shipment's COGS/Inventory posting, folded as
+> part of W-FOLD-COMPLETE's `fact_acct(319)` diff).
+
+### 3 · Customer Invoice → AR
+
+The **Invoice** the same Complete generated posts DR **Receivable** / CR **Revenue** (per line) / CR
+**Tax Due**. Open **Posting Preview** on the invoice (any completed document has this) to see that exact
+journal before you take our word for it — it is the SAME derivation the guide's Posting-Preview panel
+runs live, not a separate report-only calculation.
+
+> *Proven by* **W-POST-HARDEN** (4/4 sales invoices resolve iDempiere's exact accounts, 3/4 to the cent —
+> the 4th is a named post-posting source edit, not a derivation gap) and **W-DOC-POSTER** (the shipped
+> `derivePostings` verb Posting Preview actually calls, oracle-anchored to `fact_acct(318)`).
+
+### 4 · Receipt & Allocation
+
+Record the customer's **Payment** (⌂ → Sales → Payment) against the invoice, then run **Allocation**
+to match payment to invoice. A real allocation is rarely an exact match — it can carry a discount, a
+write-off, and (because tax rides the discounted portion too) a VAT correction, all in the same posting.
+
+> *Proven by* **W-FOLD-PAYMENT** (`Doc_Payment` receipt, `fact_acct(335)` `maxDiff=0c`) and
+> **W-FOLD-ALLOC** / **W-FOLD-ALLOC-FX** (the allocation incl. discount/write-off + the proportional
+> tax-correction sub-cents, `fact_acct(735)` `maxDiff=0c` in both the home and the foreign-currency
+> accounting schema).
+
+### 5 · Replenishment
+
+The sale just dropped on-hand. Rather than reorder on every sale, **Replenishment** is the staged,
+reviewable action that watches on-hand against each product's stock policy and proposes what to order —
+walked in detail in the POS section's **§P-4** (the same engine, reached from a friendlier panel there;
+not repeated here).
+
+> *Proven by* **W-FOLD-REPLENISH** (the proposal == iDempiere's own `ReplenishReport` formula-SQL,
+> 8/8 products, `maxDiff=0`).
+
+### 6 · Purchase Order → Receipt → Vendor Invoice → Match
+
+Accepting a replenishment proposal (or creating one by hand, ⌂ → Purchasing → Purchase Order) and
+completing it is the buy-side mirror of step 1. It flows the same way in reverse:
+
+- **Purchase Order** — completes; under this seed's configuration (commitment accounting off) it posts
+ no commitment/reservation entry — an honest, config-derived ∅, not a gap.
+- **Receipt** (`M_InOut`, incoming) — stock arrives, on-hand rises.
+- **Vendor Invoice** — posts DR **Inventory Clearing** / CR **Vendor Liability**.
+- **Match** (`M_MatchInv`) — clears the Inventory-Clearing/Not-Invoiced-Receipts pair the receipt and
+ invoice each opened, including any invoice-price-variance split against on-hand at match time.
+ `M_MatchPO`'s own leg stays an honest ∅ under this seed's Average costing (the variance-posting branch
+ only fires under Standard costing) — a config fact, proven by flipping it in the witness, not assumed.
+
+> *Proven by* **W-MORDER-POST** (the PO's zero-set is config-derived, the order-posting chain diffs per
+> fact line against the real oracle), **W-FOLD-AP-INVOICE** (4/4 vendor invoices, exact accounts,
+> `maxDiff=0c`), **W-FOLD-MATCHINV** / **W-FOLD-MATCHINV-FX** (18/18 matches incl. the IPV split, both
+> accounting schemas, `maxDiff=0c`), and **W-POST-TAIL** (`M_MatchPO`'s ∅ over all 37 real matched docs,
+> falsified by flipping the costing method to prove the gate — not the witness — is what closes it).
+
+### 7 · Final accounts
+
+Two more postings close the period: a **GL Journal** for anything that isn't a document (a manual
+accrual, an inter-org transfer) and a **Bank Statement** reconciling the bank's own records against
+what the ledger expects. Once every document for the period is posted, the period's `fact_acct` **is**
+the ledger — there is no separate "close" step that recomputes anything; Trial Balance is a live fold
+of the same rows, and it balances to the cent.
+
+> *Proven by* **W-FOLD-GLJOURNAL** (manual journal incl. per-org intercompany balancing, both schemas,
+> `maxDiff=0c`) and **W-POST-TAIL**'s `C_BankStatement` band (the real 13-row statement, `maxDiff=0c`
+> incl. the currency-balancing residual). Trial Balance: `ΣDr==ΣCr=46574.97` over the real GardenWorld
+> ledger (`test_report_fin.js`).
+
+### 8 · Financial reporting
+
+From here, everything is read-only fold: **Balance Sheet**, **Income Statement**, **Cash Flow**, and
+**Trial Balance** are all oracle-equivalent to the real ledger (§8 *Financial Reporting*, below, has the
+per-report cell counts), and the same ledger is what the **NinjaExcel** workbook lens (§8, "Excel
+Report") binds into your own spreadsheet layout.
+
+### Also in the books: fixed assets and projects
+
+Six more document classes — asset addition, transfer, revaluation, and disposal, plus depreciation
+entries and project-issue postings — fold to the cent against the real compiled posters the same way
+(oracle-generated on a scratch clone over a GardenWorld-model seed, since this demo tenant carries no
+source documents for them out of the box). They ride the same Posting-Preview/Trial-Balance surfaces as
+everything above; see `docs/internal/ERP_COVERAGE_MATRIX.md` (B-3, W-POST-B3) for the per-class detail —
+this guide's walkthrough stays the trade cycle.
+
+---
+
## 7. POS — Point of Sale
**Prerequisite:** log in as **GardenUser** (the POS station `c_pos_id=1` is scoped to that role).
diff --git a/docs/internal/ERP_COVERAGE_MATRIX.md b/docs/internal/ERP_COVERAGE_MATRIX.md
index f4dd3d516..46100f9dc 100644
--- a/docs/internal/ERP_COVERAGE_MATRIX.md
+++ b/docs/internal/ERP_COVERAGE_MATRIX.md
@@ -110,9 +110,9 @@ denominator, not 496 classes / 735k LOC).
| ✅ **oracle-equivalent** | **0-seed posting oracles — the 6 G-seed classes (B-3, W-POST-B3, ledger 43→49)** — `poc_post_b3.js` diffs the EXTENDED `doc_poster.derivePostings` (per-class manifests: `fa-addition`/`fa-depreciation`/`fa-reval`/`fa-transfer`/`fa-disposal`/`project-issue`, each citing its Doc_*.java lines) against a **GENERATED oracle**: the REAL compiled posters driven inside iDempiere's own OSGi test harness (`scripts/logic_oracle/PostingOracleTest.java` under `org.idempiere.test` tycho-surefire — resolves the G-3 "no headless OSGi" landmine) on a SCRATCH clone `idempiere_b3` of `idempiere_test`, over a GardenWorld-model seed (vendor FixedAssetsTest recipe; USER RULING 2026-07-17: seed INPUT prep sanctioned, facts never hand-authored). Capture = `scripts/generate_post_oracle.sh` → TEXT fixture `build/erp/oracle/post_b3_fixture.json` (34 fact rows, ΣDR=ΣCR=81360.56, both schemas 101+200000). Result: **all 6 classes `maxDiff=0c` per document × schema × (account,side)** — `§B3-POST`: A_Asset_Addition (4 docs incl. the A_CapvsExp=Exp arm-Z doc posting ZERO rows, gate=Doc_AssetAddition.java:67-72, **+ the fx-rate pick MConversionRate:243-252 the diff CAUGHT** — engine had picked the seed's 0.8006 twin row where the real poster picks 0.85; root cause = the twin is `IsActive='N'` and `:251` filters it (first mis-read as client-rank ordering, corrected 2026-07-18 when poc_alloc_fx §FALSIFIER-B exposed the capture-side filter masking it); `fxRate` now carries the verbatim clauses and the inactive row stays captured) · A_Depreciation_Entry (per-depexp DR/CR; schema-200000 = the other-schema ∅ branch) · A_Asset_Reval (2 delta pairs vs the seeded A_Reval_Cost_Offset_Acct) · A_Asset_Transfer (workfile cost old→new combos; the completeIt validfrom time-slice keeps pre-transfer docs derivable) · A_Asset_Disposed (from the per-schema A_Asset_Change 'DIS' row) · C_ProjectIssue (PJ_WIP vs Product.Asset at the schema costing-method cost — the W-FOLD-MOVEMENT hop). 2 §FALSIFIERs load-bearing (gate-flip Cap→Exp → 2→0 lines; double one depexp expense → ΣDR 55556c→111112c) + the 2 triage falsifiers. The 2 ∅-by-design classes (C_BankTransfer/C_DepositBatch — no Doc_ in the factory) stay structurally CLOSED. Regressions green after the doc_poster extension: W-POST-HARDEN · W-FACTACCT-DOC · W-DOC-POSTER · W-MORDER-POST · test_report_fin 46574.97/300 rows intact. `build/erp/poc_post_b3.log` · `build/erp/generate_post_oracle.log`. |
-| ✅ **oracle-equivalent** | **Doc_* poster tail — BankStatement + the config-∅ pair (W-POST-TAIL, 2026-07-18, ledger 49→52)** — `poc_post_tail.js` closes 3 more of the last 6 posters, each against a REAL seed oracle (no generation needed): **C_BankStatement (392)** — the `bank-statement` manifest (Doc_BankStatement.createFacts:200-280: per line {Bank.Asset}=+StmtAmt · {Bank.InTransit}=−TrxAmt · charge >0→CR/<0→DR · interest <0→InterestExp else Rev; DOC-currency legs converted per schema + the Fact.balanceAccounting CurrencyBalancing residual — the REAL 0.01 DR on schema 200000) **== the 13 posted fact rows, maxDiff=0c** per doc × schema × (account, side). **M_MatchPO (473)** — the REAL engine posted the EMPTY set for all **37** posted docs: PPV gated on StandardCosting (Doc_MatchPO.java:429), seed costs at 'A' → engine ∅ == oracle ∅ across the full population; §FALSIFIER flips costingmethod→'S' + cost≠price → the PPV path opens. **M_Requisition (702)** — 1 posted doc, ∅ by `MAcctSchema.isCreateReservation` (:662-669, commitmenttype='N'); §FALSIFIER flips 'N'→'B' → expense+commitment lines appear (the W-MORDER-POST twin). 3rd falsifier: doubling a stmt line breaks the bank diff (14850c→24650c). Additive captures only (c_bankstatementline amounts/charge, bank interest accts, c_charge_acct, m_matchpo, m_requisitionline, rate isactive) — whole posting bundle + TB 46574.97/300 re-verified green. **Named skips:** C_Cash (2 real CO docs never posted → post on scratch clone next), M_Inventory (3 drafts → complete+post next), M_Production (0 docs + no component costs → ⛔, costs not synthesized). `build/erp/poc_post_tail.log`. |
+| ✅ **oracle-equivalent** | **Doc_* poster tail — BankStatement + the config-∅ pair (W-POST-TAIL, 2026-07-18, ledger 49→52)** — `poc_post_tail.js` closes 3 more of the last 6 posters, each against a REAL seed oracle (no generation needed): **C_BankStatement (392)** — the `bank-statement` manifest (Doc_BankStatement.createFacts:200-280: per line {Bank.Asset}=+StmtAmt · {Bank.InTransit}=−TrxAmt · charge >0→CR/<0→DR · interest <0→InterestExp else Rev; DOC-currency legs converted per schema + the Fact.balanceAccounting CurrencyBalancing residual — the REAL 0.01 DR on schema 200000) **== the 13 posted fact rows, maxDiff=0c** per doc × schema × (account, side). **M_MatchPO (473)** — the REAL engine posted the EMPTY set for all **37** posted docs: PPV gated on StandardCosting (Doc_MatchPO.java:429), seed costs at 'A' → engine ∅ == oracle ∅ across the full population; §FALSIFIER flips costingmethod→'S' + cost≠price → the PPV path opens. **M_Requisition (702)** — 1 posted doc, ∅ by `MAcctSchema.isCreateReservation` (:662-669, commitmenttype='N'); §FALSIFIER flips 'N'→'B' → expense+commitment lines appear (the W-MORDER-POST twin). 3rd falsifier: doubling a stmt line breaks the bank diff (14850c→24650c). Additive captures only (c_bankstatementline amounts/charge, bank interest accts, c_charge_acct, m_matchpo, m_requisitionline, rate isactive) — whole posting bundle + TB 46574.97/300 re-verified green. **C_Cash + M_Inventory closed 2026-07-18 (§W-POST-TAIL-2, empirically driven, ledger STAYS 52):** drove the REAL compiled posters on a FRESH scratch clone `idempiere_tail` (`scripts/generate_post_tail_oracle.sh` + `scripts/logic_oracle/PostingTailTest.java`, zero seed authoring — every document already existed) over the 2 real CO cash journals + 3 real inventory drafts. Result: **both classes are ∅-by-DATA-STATE, not ∅-by-config** — a new, distinct category, each precisely engine-cited: **C_Cash (407)** — BOTH docs (100/101) carry `IsActive='N'`; `Doc.postIt`'s lock UPDATE (`Doc.java:591-605`) requires `IsActive='Y'` before `createFacts` ever runs → `DocManager.postDocument` returns `"CannotPostInactiveDocument"` for both; NOT worked around (flipping `IsActive` on an existing document is data mutation out of the seed-prep ruling's scope). **M_Inventory (321)** — drafts 200000/200001 have ZERO lines each → `MInventory.prepareIt` (`MInventory.java:401-406`) refuses BEFORE completion with `@NoLines@`; the 1 completable draft (doc 100, product 147) DOES reach `DocStatus=Completed` but the REAL engine then REFUSES to post it — `"No Costs for TShirt - Red Large"` (`Doc_Inventory.java:319-336`: product 147 has `currentcostprice=0` everywhere and zero `M_CostDetail` rows, so the zero-cost-blessing check fails). Both manifests were still written into `doc_poster.js` (`deriveCash`/`deriveInventory`, full source-cited translations, reusable for any FUTURE active/costed document) and PROVEN live via falsifier, not oracle-diff: `deriveCash` computes REAL non-empty legs from the real line data (proving the ∅ is the `IsActive` gate, not a dead verb) and `deriveInventory` reproduces the SAME "No Costs" refusal (0==0, not vacuous — a blessing-row flip in an in-memory copy opens the gate). `build/erp/generate_post_tail_oracle.log` · `build/erp/poc_post_tail.log`. **M_Production** stays untouched: 0 docs + no component costs → ⛔, costs not synthesized. |
-**Remaining ⬜ declarative surfaces: NONE** (`ad_evaluator` fell to B-1 W-LOGIC-HARDEN, `ad_workflow` fell to B-2 W-WF-HARDEN, 2026-06-12; the 0-seed POSTING tail fell to B-3 W-POST-B3, 2026-07-17; the poster tail's seed-oracled half fell to W-POST-TAIL, 2026-07-18 — 17 of 20 factory posters now fold, Cash/Inventory next, Production ⛔).
+**Remaining ⬜ declarative surfaces: NONE** (`ad_evaluator` fell to B-1 W-LOGIC-HARDEN, `ad_workflow` fell to B-2 W-WF-HARDEN, 2026-06-12; the 0-seed POSTING tail fell to B-3 W-POST-B3, 2026-07-17; the poster tail's seed-oracled half fell to W-POST-TAIL, 2026-07-18 — **17 of 20 factory posters fold, CLOSED (2026-07-18): 3 remain ⛔, each for a DIFFERENT, precisely-cited engine-level reason — C_Cash (both real docs IsActive=N), M_Inventory (2/3 no lines + the 1 completable doc has no cost data), M_Production (0 docs + no component costs). None worked around; none is a research gap left open — the ceiling is this seed's actual data state.**).
**The honest read: coverage 37🟡 ≠ equivalence.** FIFTY-TWO surfaces/classes are now oracle-equivalent to the unit/cent
(TB-read · per-document GL derivation H-1 · `completeIt` Order→Ship→Invoice chain · Doc_Payment · Doc_AllocationHdr
diff --git a/prompts/HARDEN_MATRIX.md b/prompts/HARDEN_MATRIX.md
index 440169ff4..6c22e36a5 100644
--- a/prompts/HARDEN_MATRIX.md
+++ b/prompts/HARDEN_MATRIX.md
@@ -137,6 +137,98 @@ conversion + 0.01 CurrencyBalancing residual) · `§TAIL-POST M_MatchPO docs=37
17/20 factory posters fold. Remaining: Cash (post 2 real CO docs on clone) · Inventory (complete 3 real
drafts on clone) · Production ⛔ (no docs, no component costs). Log: `build/erp/poc_post_tail.log`.
+### §W-POST-TAIL-2 manifest spec (2026-07-18, Sonnet session — written BEFORE coding, per Spec-First)
+**C_Cash (407) — Doc_Cash.createFacts:150-249 (`Doc_Cash.java`), DocLine_Cash.java for the CashType
+constants.** Facts (re-verified live): 2 real CO docs, posted='N' — `c_cash_id=100` 1 line (CashType=I
+Invoice, amount=50.35, c_invoice_id=100) · `c_cash_id=101` 3 lines (E −10, T −50 bankaccount=100, R +10).
+No Charge/Difference lines in this seed → those two branches are manifest-complete (source-parsed) but
+UNEXERCISED by the real docs; do not claim them oracle-diffed. Per-line legs, header running `assetAmt`:
+- **E (Expense, :174-181):** DR `{CashBook.CashExpense}`=amount.negate(); assetAmt −= amount.negate().
+- **R (Receipt, :182-189):** assetAmt += amount; CR `{CashBook.CashReceipt}`=amount (raw, not abs).
+- **C (Charge, :190-197):** DR `line.getChargeAccount` (`c_charge_acct.ch_expense_acct`, C_Charge_ID/schema)
+ =amount.negate(); assetAmt −= amount.negate(). Charge_ID=0 on any real line ⇒ this branch never fires here.
+- **D (Difference, :198-205):** DR `{CashBook.CashDifference}`=amount.negate(); assetAmt += amount.
+- **I (Invoice, :206-219):** if line currency == cashbook currency (true, both 100/USD): assetAmt += amount,
+ no separate CashAsset line; ELSE DR `{CashBook.CashAsset}` at line currency. ALWAYS: CR `{CashBook.CashTransfer}`
+ =amount.negate(), at LINE currency (not doc currency).
+- **T (Transfer, :220-236):** DR `{BankAccount.InTransit}` (`c_bankaccount_acct.b_intransit_acct`, keyed by
+ the LINE's own C_BankAccount_ID/schema, not the doc's) =amount.negate(), at line currency; if line
+ currency==cashbook currency: assetAmt += amount; else DR `{CashBook.CashAsset}` at line currency.
+- **Header close (:239-243):** if assetAmt≠0, one more leg on `{CashBook.CashAsset}` (doc/cashbook
+ currency) = assetAmt (sign-dependent DR/CR, 4-arg `Fact.createLine`).
+- **Accounts:** `c_cashbook_acct` columns CB_Asset_Acct/CB_CashTransfer_Acct/CB_Expense_Acct/
+ CB_Receipt_Acct/CB_Differences_Acct keyed by (C_CashBook_ID=101, schema) — `Doc.java:1478-1502`.
+ Schema 200000 carries ONLY `cb_cashtransfer_acct` (200065) — asset/expense/receipt/differences are
+ NULL there; `Fact.createLine` silently DROPS a null-account line (`Fact.java:116-122`, not an error) —
+ so schema-200000 posts a partial (possibly source-imbalanced) leg set BY DESIGN, not a bug. `Doc.getAccount`
+ returns null on a 0-combination (`Doc.java:1602-1610`), same drop behaviour.
+- **maxDiff=0c gate** over both real docs × both schemas × (account,side), same shape as BankStatement.
+
+**M_Inventory (321) — Doc_Inventory.createFacts:211-513 (`Doc_Inventory.java`), physical-inventory branch
+only** (this seed's 3 docs are all DocSubTypeInv=PI, doctype 144). Facts (re-verified live): `m_inventory_id`
+100 (DR client 11) has 1 real line (product 147 "TShirt - Red Large", locator 101→warehouse 103, qtycount=1/
+qtybook=0 → qtyDiff=+1, no charge, no ASI, not reversal) — the ONLY completable doc. **`m_inventory_id`
+200000 and 200001 have ZERO lines each** (`m_inventoryline` count=0, verified live) → `MInventory.prepareIt`
+(`MInventory.java:401-406`) fails BEFORE completion with `@NoLines@`, STATUS_Invalid — these 2 CANNOT
+reach docstatus=CO, let alone post. This is the engine's own validation, not a workaround decision:
+⛔ both BY NAME, do not attempt to seed lines onto them (that would be inventing source data on someone
+else's document, banned regardless of the seed-prep ruling — the ruling covers NEW documents, not
+patching existing ones). **1 of 3 is the honest ceiling for this seed.**
+- Manifest for doc 100 (PI, :279-340): `costs = line.getProductCosts(as, orgId, true, "M_InventoryLine_ID=?")`
+ (`ProductCost.getProductCosts`, the schema-costingmethod → cost-element → `m_cost.currentcostprice` hop,
+ same as `deriveProjectIssue`'s `{Product}` cost lookup already in `doc_poster.js`). Product 147's `m_cost`
+ rows are ALL currentcostprice=0 (schema 101; NO rows at all for schema 200000) and `m_costdetail` has
+ ZERO rows for this product (verified live) — so the `costs==0` zero-cost-blessing check
+ (`Doc_Inventory.java:319-336`, requires a processed zero-cost purchase M_CostDetail row) FAILS → the
+ REAL engine's own `createFacts` returns `p_Error="No Costs for TShirt - Red Large"` UNLESS the scratch
+ run shows otherwise (to be confirmed empirically against the actual OSGi-driven posting — the source
+ reading above is the prediction, the scratch-clone run is the oracle; if posting truly fails this is
+ ALSO an honest ⛔-by-data-state, named with the engine's exact error, not invented around).
+- IF posting succeeds (costs resolve non-zero via a path not visible from static reading — e.g. batch/lot
+ MA rows, or a costingLevel hop this trace missed): DR `{Product.Asset}` (service→`{Product.Expense}`,
+ `ProductCost.ACCTTYPE_P_Asset`, m_product_category_acct via product→category, same token already in
+ `post_resolver.js`) = costs; CR `line.getChargeAccount` if C_Charge_ID≠0 (0 here → null) ELSE
+ `M_Warehouse_Acct.W_Differences_Acct` keyed by (M_Warehouse_ID=103, schema) — `Doc.java:1505-1509`,
+ `Doc.ACCTTYPE_InvDifferences` — = costs.negate(). `M_Warehouse_ID` resolved from the line's locator
+ (`m_locator.m_warehouse_id`), not a doc column.
+- Capture additive: `m_inventory`, `m_inventoryline`, `m_warehouse_acct`, `m_locator` (locator→warehouse),
+ `m_cost`, `m_costelement`, `m_costdetail` (for the zero-cost-blessing count), `m_product`,
+ `m_product_category_acct`, `c_validcombination`.
+
+Witness extension: new diff bands in `scripts/poc_post_tail.js` reading a NEW fixture
+`build/erp/oracle/post_tail_fixture.json` (twin of `post_b3_fixture.json`, via a new
+`scripts/logic_oracle/PostingTailTest.java` + `scripts/generate_post_tail_oracle.sh` +
+`scripts/capture_post_tail_fixture.js` — same scratch-clone-drive-capture-drop machinery, reused not
+reinvented), same maxDiff=0c per-doc/per-schema gate. `M_Production` ⛔ stays untouched (0 docs).
+
+**✅ §W-POST-TAIL-2 CLOSED 2026-07-18 (same session as the spec above) — empirically driven, NOT the
+optimistic outcome the spec predicted.** Ran the REAL compiled posters on a fresh scratch clone
+`idempiere_tail` over the 2 real CO cash journals + 3 real inventory drafts (zero seed authoring — every
+document already existed). Ground truth (`§TAILORACLE`, `build/erp/generate_post_tail_oracle.log`):
+**C_Cash — both docs (100/101) are `IsActive='N'`**, not merely "never posted" as first assumed;
+`Doc.postIt`'s lock UPDATE (`Doc.java:591-605`, requires `IsActive='Y'`) never fires →
+`DocManager.postDocument` returns `"CannotPostInactiveDocument"` for both. **M_Inventory — 200000/200001
+have ZERO lines each** → `MInventory.prepareIt` (`:401-406`) refuses with `@NoLines@` before completion;
+**doc 100 (1 line, product 147) DOES complete** (DocStatus=Completed) but the REAL engine then refuses to
+POST it — `"No Costs for TShirt - Red Large"` (`Doc_Inventory.java:319-336` — product 147 has
+`currentcostprice=0` everywhere and ZERO `M_CostDetail` rows, so the zero-cost-blessing check fails).
+**Neither blocker worked around** — flipping `IsActive` or seeding lines/costs onto an EXISTING document
+is data mutation, the same out-of-scope boundary this very card already drew for Inventory's `@NoLines@`
+docs; extending it to Cash's `IsActive` flag is the consistent, non-invented call, not a new judgment.
+`doc_poster.js` still gained `deriveCash` (all 6 CashType legs, Doc_Cash.createFacts:150-249, fully
+source-cited) and `deriveInventory` (physical-inventory branch, Doc_Inventory.createFacts:211-513) —
+reusable for any FUTURE active/costed document — proven LIVE via falsifier since no oracle-diff is
+possible here: `deriveCash` computes real non-empty legs from the real line data (§TAIL-FALSIFIER
+cash-live, doc100 2 lines / doc101 4 lines — the ∅ is the `IsActive` gate, not a dead verb);
+`deriveInventory` reproduces the SAME "No Costs" refusal (0==0, non-vacuous — §TAIL-FALSIFIER
+inv-bless-flip: a synthetic zero-cost-blessing row in an in-memory copy opens the gate, 0→2 lines).
+**Ledger STAYS 52 / 17 of 20 posters** — Cash and Inventory join Production as named ⛔, each for a
+different, precisely-cited reason; none is a "next session" placeholder anymore. Whole bundle re-run
+green (`poc_post_b3` · `poc_post_harden` · `poc_factacct_doc` · `poc_doc_poster` · `poc_morder_post` ·
+`poc_alloc_fx` · `poc_money_post` · `poc_matchinv_fx` · `poc_gljournal` · `poc_post_tail` ·
+`test_report_fin` all exit 0, TB 46574.97/300 intact). Logs: `build/erp/generate_post_tail_oracle.log` ·
+`build/erp/poc_post_tail.log`.
+
### H-3 Spot-harden the declarative engines
`ad_evaluator`/`ad_access`/`ad_valrule`/`ad_reference` are 🟡 on parse; oracle-diff a SAMPLE of each against
`GridField`/`MRole`/`MValRule` outputs — confirm the verdict matches, not just that it parses. The master-data
diff --git a/prompts/RESUME_ERP_POST_TAIL_AND_USERGUIDE.md b/prompts/RESUME_ERP_POST_TAIL_AND_USERGUIDE.md
index 7e8e3d7de..ec3d6c86f 100644
--- a/prompts/RESUME_ERP_POST_TAIL_AND_USERGUIDE.md
+++ b/prompts/RESUME_ERP_POST_TAIL_AND_USERGUIDE.md
@@ -111,3 +111,54 @@ so the guide inherits credibility without turning into a test report:
## SESSION END
- Every §A/§B item ✅ or ⛔-with-the-one-question. Update THIS file's DONE appendix (§-lined), PROGRESS.md,
push everything (zero local-only commits), PR + merge per the git note at top.
+
+# DONE — 2026-07-18, Sonnet session (§A + §B both worked to zero). Every claim = a § line in
+# `build/erp/generate_post_tail_oracle.log` / `build/erp/poc_post_tail.log` (exit 0, READ).
+
+- **§A-1/§A-2 CLOSED, empirically — NOT the outcome this card predicted.** Read `Doc_Cash.createFacts`
+ (Doc_Cash.java:150-249) and `Doc_Inventory.createFacts` (Doc_Inventory.java:211-513) first, wrote the
+ manifest spec into `HARDEN_MATRIX.md §W-POST-TAIL-2` before coding (per Spec-First), then built the
+ reusable machinery: `scripts/logic_oracle/PostingTailTest.java` (sibling of `PostingOracleTest.java`,
+ same vendor OSGi harness) + `scripts/generate_post_tail_oracle.sh` (twin of `generate_post_oracle.sh`,
+ scratch clone `idempiere_tail`) + `scripts/capture_post_tail_fixture.js` → `build/erp/oracle/
+ post_tail_fixture.json`. Drove the REAL engine over the REAL 2 CO cash journals + 3 inventory drafts —
+ ZERO seed authoring, every document already existed. Result: **both classes are ∅-by-DATA-STATE**,
+ distinct from B-3's seed-generation and from MatchPO/Requisition's config-gates: `§TAILORACLE class=
+ C_Cash record_id=100/101 isactive=N` (BOTH real docs — `Doc.java:591-605`'s lock UPDATE requires
+ `IsActive='Y'`, `DocManager.postDocument` returns `"CannotPostInactiveDocument"`); `§TAILORACLE class=
+ M_Inventory record_id=200000/200001 processIt_ok=false processMsg=@NoLines@` (zero lines each,
+ `MInventory.java:401-406`); `§TAILORACLE class=M_Inventory record_id=100 ... postErr=Posting Error
+ (No Costs for TShirt - Red Large)` (the 1 completable draft — product 147 has `currentcostprice=0`
+ everywhere and 0 `M_CostDetail` rows, `Doc_Inventory.java:319-336`). **Neither blocker worked
+ around** — flipping `IsActive` or seeding lines/costs onto an EXISTING document is data mutation, the
+ same out-of-scope boundary this card itself drew for Inventory's `@NoLines@` docs (not a new judgment
+ call, the consistent application of it). `doc_poster.js` gained `deriveCash` + `deriveInventory`
+ anyway (full source-cited translations, reusable for any FUTURE active/costed document) and proved
+ them LIVE via falsifier since no oracle-diff is possible: `§TAIL-FALSIFIER cash-live doc100_lines=2
+ doc101_lines=4` (the manifest computes real non-empty legs — the ∅ is the `IsActive` gate, not a dead
+ verb) and `§TAIL-FALSIFIER inv-bless-flip doc=100 before_lines=0 after_lines=2` (the manifest
+ reproduces the SAME "No Costs" refusal, 0==0 non-vacuously, and a synthetic blessing row opens it).
+ **Ledger STAYS 52 / 17-of-20 posters** (not 54 — the card's own optimistic headline was wrong; this is
+ the corrected, evidence-based number). Whole regression bundle re-run green: `poc_post_b3` ·
+ `poc_post_harden` · `poc_factacct_doc` · `poc_doc_poster` · `poc_morder_post` · `poc_alloc_fx` ·
+ `poc_money_post` · `poc_matchinv_fx` · `poc_gljournal` · `poc_post_tail` · `test_report_fin` all exit 0,
+ TB 46574.97/300 intact. Banked: `docs/internal/ERP_COVERAGE_MATRIX.md` (the W-POST-TAIL row extended +
+ the "17 of 20" summary line corrected), `prompts/HARDEN_MATRIX.md` (§W-POST-TAIL-2 CLOSED band),
+ `PROGRESS.md` (archive line + OPEN line updated).
+- **§B-1/§B-2 DONE** — `docs/ERPUserGuide.md` gained **"The lay of the land"** (inserted after Quick
+ start: a mermaid map + prose walking front-door → Login → Pill Bar → Windows/Tabs/Fields → Process
+ Button → where documents live → where the books live, every claim pointing at an existing section) and
+ **"The standard flow — order to cash, procure to pay, books to reports"** (inserted after §6 Process
+ Button, before §7 POS, matching this guide's existing convention of unnumbered thematic sections
+ interleaved with the numbered core-manual sections — renumbering the 15 numbered sections was
+ considered and rejected as unnecessary blast radius for a doc-only change). All 8 steps told as one
+ continuous story over the GardenWorld demo data (Sales Order → Shipment → Invoice/AR → Receipt &
+ Allocation → Replenishment → PO/Receipt/Vendor-Invoice/Match → Final accounts → Financial reporting),
+ each citing its real witness (W-FOLD-COMPLETE/QTYONHAND/INOUTGL/PAYMENT/ALLOC/REPLENISH/MORDER-POST/
+ AP-INVOICE/MATCHINV/GLJOURNAL/POST-TAIL/PA-REPORT) plus a one-paragraph "also in the books" note for
+ the B-3 fixed-asset/project classes. POS/Kitchen/WH/Tenancy/4D-5D/Ninja addon lenses untouched, per
+ scope. Verified: `mkdocs build --strict` clean (no new broken anchors/warnings vs. the pre-existing
+ HRBIMAssetGuide.md ones), mermaid fence syntax matches the one other file already using it
+ (`docs/internal/FoldEngineQuality.md`) — confirms the pattern is established, not novel.
+- **Housekeeping:** this card's own DONE appendix is the record (per `feedback_prompt_file_organization`
+ rule 0 — a session working a `prompts/#.md` file updates only that file, not `MEMORY.md`).
diff --git a/scripts/capture_post_tail_fixture.js b/scripts/capture_post_tail_fixture.js
new file mode 100644
index 000000000..37a457aa3
--- /dev/null
+++ b/scripts/capture_post_tail_fixture.js
@@ -0,0 +1,160 @@
+// Copyright (c) 2025-2026 Redhuan D. Oon
+// SPDX-License-Identifier: MIT
+// ⚠ DO NOT REMOVE — Scope guard
+// Scope: W-POST-TAIL §W-POST-TAIL-2 fixture capture (prompts/HARDEN_MATRIX.md). Twin of
+// capture_post_b3_fixture.js: straight copy of the COMMITTED scratch DB (idempiere_tail) after
+// scripts/logic_oracle/PostingTailTest.java drove the REAL compiled Doc_Cash/Doc_Inventory posters over
+// REAL pre-existing documents (no seed authoring at all here — unlike B-3). NON-INVENT: every row below
+// is read from the scratch PG; nothing is computed or authored here.
+// Called by scripts/generate_post_tail_oracle.sh; standalone: node scripts/capture_post_tail_fixture.js
+'use strict';
+var cp = require('child_process');
+var fs = require('fs');
+var path = require('path');
+
+var DB = process.argv[2] || 'idempiere_tail';
+var OUT = process.argv[3] || path.join(__dirname, '..', 'build', 'erp', 'oracle', 'post_tail_fixture.json');
+var US = '\x1f';
+
+function pgRows(sql) {
+ var out = cp.execFileSync('docker', ['exec', 'postgres', 'psql', '-U', 'adempiere', '-d', DB,
+ '-t', '-A', '-F', US, '-c', sql], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
+ return out.split('\n').filter(function (s) { return s.length; }).map(function (l) { return l.split(US); });
+}
+
+// tail target ad_table_ids: C_Cash=407, M_Inventory=321
+var IDS = '407,321';
+
+var CAPTURE = {
+ fact_acct: {
+ cols: ['fact_acct_id','ad_client_id','ad_org_id','c_acctschema_id','account_id','c_period_id',
+ 'ad_table_id','record_id','line_id','gl_category_id','c_tax_id','postingtype','c_currency_id',
+ 'amtsourcedr','amtsourcecr','amtacctdr','amtacctcr','qty','m_product_id','c_bpartner_id','description'],
+ sql: "SELECT fact_acct_id, ad_client_id, ad_org_id, c_acctschema_id, account_id, c_period_id," +
+ " ad_table_id, record_id, line_id, gl_category_id, c_tax_id, postingtype, c_currency_id," +
+ " round(amtsourcedr,2), round(amtsourcecr,2), round(amtacctdr,2), round(amtacctcr,2)," +
+ " round(qty,2), m_product_id, c_bpartner_id, replace(coalesce(description,''),chr(10),' ')" +
+ " FROM adempiere.fact_acct WHERE ad_table_id IN (" + IDS + ") ORDER BY fact_acct_id"
+ },
+ c_cashbook: {
+ cols: ['c_cashbook_id','c_currency_id','ad_org_id'],
+ sql: "SELECT c_cashbook_id, c_currency_id, ad_org_id FROM adempiere.c_cashbook WHERE c_cashbook_id=101"
+ },
+ c_cash: {
+ cols: ['c_cash_id','c_cashbook_id','statementdate','docstatus','posted','isactive','ad_org_id','ad_client_id'],
+ sql: "SELECT c_cash_id, c_cashbook_id, statementdate::date, docstatus, posted, isactive, ad_org_id, ad_client_id" +
+ " FROM adempiere.c_cash WHERE c_cash_id IN (100,101) ORDER BY c_cash_id"
+ },
+ c_cashline: {
+ cols: ['c_cashline_id','c_cash_id','cashtype','c_currency_id','amount','c_bankaccount_id','c_invoice_id','c_charge_id'],
+ sql: "SELECT c_cashline_id, c_cash_id, cashtype, c_currency_id, round(amount,2)," +
+ " coalesce(c_bankaccount_id,0), coalesce(c_invoice_id,0), coalesce(c_charge_id,0)" +
+ " FROM adempiere.c_cashline WHERE c_cash_id IN (100,101) ORDER BY c_cashline_id"
+ },
+ c_cashbook_acct: {
+ cols: ['c_cashbook_id','c_acctschema_id','cb_asset_acct','cb_cashtransfer_acct','cb_expense_acct','cb_receipt_acct','cb_differences_acct'],
+ sql: "SELECT c_cashbook_id, c_acctschema_id, coalesce(cb_asset_acct,0), coalesce(cb_cashtransfer_acct,0)," +
+ " coalesce(cb_expense_acct,0), coalesce(cb_receipt_acct,0), coalesce(cb_differences_acct,0)" +
+ " FROM adempiere.c_cashbook_acct WHERE c_cashbook_id=101 ORDER BY c_acctschema_id"
+ },
+ c_bankaccount_acct: {
+ cols: ['c_bankaccount_id','c_acctschema_id','b_intransit_acct'],
+ sql: "SELECT c_bankaccount_id, c_acctschema_id, coalesce(b_intransit_acct,0)" +
+ " FROM adempiere.c_bankaccount_acct WHERE c_bankaccount_id=100 ORDER BY c_acctschema_id"
+ },
+ c_charge_acct: {
+ cols: ['c_charge_id','c_acctschema_id','ch_expense_acct'],
+ sql: "SELECT c_charge_id, c_acctschema_id, coalesce(ch_expense_acct,0) FROM adempiere.c_charge_acct" +
+ " WHERE ad_client_id=11 ORDER BY c_charge_id, c_acctschema_id"
+ },
+ m_inventory: {
+ cols: ['m_inventory_id','docstatus','posted','movementdate','m_warehouse_id','c_doctype_id','ad_org_id','ad_client_id'],
+ sql: "SELECT m_inventory_id, docstatus, posted, movementdate::date, m_warehouse_id, c_doctype_id, ad_org_id, ad_client_id" +
+ " FROM adempiere.m_inventory WHERE m_inventory_id IN (100,200000,200001) ORDER BY m_inventory_id"
+ },
+ m_inventoryline: {
+ cols: ['m_inventoryline_id','m_inventory_id','m_product_id','m_locator_id','qtycount','qtybook',
+ 'qtyinternaluse','m_attributesetinstance_id','c_charge_id','isactive','reversalline_id'],
+ sql: "SELECT m_inventoryline_id, m_inventory_id, m_product_id, m_locator_id, round(qtycount,2), round(qtybook,2)," +
+ " round(coalesce(qtyinternaluse,0),2), coalesce(m_attributesetinstance_id,0), coalesce(c_charge_id,0)," +
+ " isactive, coalesce(reversalline_id,0)" +
+ " FROM adempiere.m_inventoryline WHERE m_inventory_id IN (100,200000,200001) ORDER BY m_inventoryline_id"
+ },
+ c_doctype: {
+ cols: ['c_doctype_id','docsubtypeinv'],
+ sql: "SELECT c_doctype_id, coalesce(docsubtypeinv,'') FROM adempiere.c_doctype WHERE c_doctype_id=144"
+ },
+ m_locator: {
+ cols: ['m_locator_id','m_warehouse_id'],
+ sql: "SELECT m_locator_id, m_warehouse_id FROM adempiere.m_locator WHERE m_locator_id=101"
+ },
+ m_warehouse_acct: {
+ cols: ['m_warehouse_id','c_acctschema_id','w_differences_acct'],
+ sql: "SELECT m_warehouse_id, c_acctschema_id, coalesce(w_differences_acct,0) FROM adempiere.m_warehouse_acct" +
+ " WHERE m_warehouse_id=103 ORDER BY c_acctschema_id"
+ },
+ m_product: {
+ cols: ['m_product_id','producttype','isstocked','m_product_category_id','name'],
+ sql: "SELECT m_product_id, producttype, isstocked, m_product_category_id, name FROM adempiere.m_product" +
+ " WHERE m_product_id=147"
+ },
+ m_product_category_acct: {
+ cols: ['m_product_category_id','c_acctschema_id','p_asset_acct','p_expense_acct'],
+ sql: "SELECT m_product_category_id, c_acctschema_id, coalesce(p_asset_acct,0), coalesce(p_expense_acct,0)" +
+ " FROM adempiere.m_product_category_acct WHERE m_product_category_id=105 ORDER BY c_acctschema_id"
+ },
+ m_cost: {
+ cols: ['m_product_id','c_acctschema_id','m_costtype_id','m_costelement_id','currentcostprice'],
+ sql: "SELECT m_product_id, c_acctschema_id, m_costtype_id, m_costelement_id, currentcostprice" +
+ " FROM adempiere.m_cost WHERE m_product_id=147 ORDER BY c_acctschema_id, m_costelement_id"
+ },
+ m_costelement: {
+ cols: ['m_costelement_id','costingmethod','costelementtype'],
+ sql: "SELECT m_costelement_id, coalesce(costingmethod,''), costelementtype FROM adempiere.m_costelement" +
+ " WHERE ad_client_id IN (0,11) ORDER BY m_costelement_id"
+ },
+ m_costdetail: {
+ cols: ['m_costdetail_id','m_product_id','processed','amt','qty','c_orderline_id','c_invoiceline_id'],
+ sql: "SELECT m_costdetail_id, m_product_id, processed, round(amt,2), round(qty,2)," +
+ " coalesce(c_orderline_id,0), coalesce(c_invoiceline_id,0)" +
+ " FROM adempiere.m_costdetail WHERE m_product_id=147 ORDER BY m_costdetail_id"
+ },
+ c_acctschema: {
+ cols: ['c_acctschema_id','c_currency_id','costingmethod','m_costtype_id'],
+ sql: "SELECT c_acctschema_id, c_currency_id, costingmethod, m_costtype_id FROM adempiere.c_acctschema" +
+ " WHERE ad_client_id=11 ORDER BY c_acctschema_id"
+ },
+ c_validcombination: {
+ cols: ['c_validcombination_id','account_id'],
+ sql: "SELECT c_validcombination_id, account_id FROM adempiere.c_validcombination WHERE ad_client_id IN (0,11)" +
+ " ORDER BY c_validcombination_id"
+ }
+};
+
+var fixture = { db: DB, tables: {} };
+Object.keys(CAPTURE).forEach(function (t) {
+ var spec = CAPTURE[t];
+ var rows = pgRows(spec.sql);
+ rows.forEach(function (r) {
+ if (r.length !== spec.cols.length) {
+ throw new Error('capture ' + t + ': row width ' + r.length + ' != cols ' + spec.cols.length);
+ }
+ });
+ fixture.tables[t] = { cols: spec.cols, rows: rows };
+ console.log('§GEN-CAPTURE table=' + t + ' rows=' + rows.length);
+});
+
+var fa = fixture.tables.fact_acct.rows;
+var dr = 0, cr = 0;
+fa.forEach(function (r) { dr += Math.round(Number(r[15]) * 100); cr += Math.round(Number(r[16]) * 100); });
+console.log('§GEN-CAPTURE fact_acct tail-classes rows=' + fa.length + ' ΣDRc=' + dr + ' ΣCRc=' + cr);
+// NOTE: unlike B-3, a 0-row fact_acct here is NOT automatically refused — the honest outcome could be
+// "C_Cash posted, M_Inventory refused (No Costs)" i.e. partial. The witness (poc_post_tail.js) enforces
+// the real per-class expectation; this capture just copies whatever the engine actually wrote.
+if (fa.length === 0) {
+ console.log('§GEN-CAPTURE WARNING 0 fact_acct rows for tail classes — check §TAILORACLE log for postErr');
+}
+
+fs.mkdirSync(path.dirname(OUT), { recursive: true });
+fs.writeFileSync(OUT, JSON.stringify(fixture, null, 1));
+console.log('§GEN-CAPTURE fixture=' + OUT + ' bytes=' + fs.statSync(OUT).size);
diff --git a/scripts/doc_poster.js b/scripts/doc_poster.js
index a95b128b7..1714fcb15 100644
--- a/scripts/doc_poster.js
+++ b/scripts/doc_poster.js
@@ -411,6 +411,117 @@ function deriveRequisition(db, id, schema) {
return d;
}
+// Doc_Cash.createFacts:150-249 (HARDEN_MATRIX.md §W-POST-TAIL-2) — per c_cashline CashType leg + the
+// header running assetAmt close. NOT oracle-diffable in THIS seed: both real c_cash docs (100/101) carry
+// IsActive='N' — Doc.postIt's lock UPDATE (Doc.java:591-605) requires IsActive='Y' before createFacts
+// ever runs, so the REAL engine posts ZERO rows for these two (verified live: DocManager.postDocument →
+// "CannotPostInactiveDocument"). This manifest is a faithful, source-cited translation (reusable for any
+// FUTURE active C_Cash doc) — its role HERE is only the falsifier: it computes REAL non-empty legs from
+// the real line data, proving the ∅ is an IsActive-gate fact (Doc.postIt, outside createFacts), not a
+// dead/no-op verb or a manifest bug. NEVER invents: IsActive is read, never flipped, on the real rows.
+function deriveCash(db, id, schema) {
+ var hdr = getRow(db, 'SELECT * FROM c_cash WHERE c_cash_id=?', num(id));
+ if (!hdr) return null;
+ var cb = getRow(db, 'SELECT * FROM c_cashbook WHERE c_cashbook_id=?', num(hdr.c_cashbook_id));
+ var cbAcct = getRow(db, 'SELECT * FROM c_cashbook_acct WHERE c_cashbook_id=? AND c_acctschema_id=?', [num(hdr.c_cashbook_id), num(schema)]);
+ var docCur = cb ? num(cb.c_currency_id) : null;
+ var d = b3New();
+ if (!cbAcct) { d.absent.push('c_cashbook_acct#' + hdr.c_cashbook_id + '/' + schema); return d; }
+ var lines = allRows(db, 'SELECT * FROM c_cashline WHERE c_cash_id=? ORDER BY c_cashline_id', num(id));
+ var assetAmt = 0;
+ function cbEl(col, token) { return elOf(db, vcAcct(db, cbAcct[col]), d.absent, token); }
+ lines.forEach(function (l) {
+ var amt = cents(l.amount);
+ var lineCur = num(l.c_currency_id);
+ if (l.cashtype === 'E') { // Expense :174-181
+ var expEl = cbEl('cb_expense_acct', '{CashBook.CashExpense}');
+ if (expEl) d.add('DR', expEl, -amt);
+ assetAmt -= -amt;
+ } else if (l.cashtype === 'R') { // Receipt :182-189
+ assetAmt += amt;
+ var rcvEl = cbEl('cb_receipt_acct', '{CashBook.CashReceipt}');
+ if (rcvEl) d.add('CR', rcvEl, amt);
+ } else if (l.cashtype === 'C') { // Charge :190-197
+ var chg = getRow(db, 'SELECT ch_expense_acct AS acct FROM c_charge_acct WHERE c_charge_id=? AND c_acctschema_id=?', [num(l.c_charge_id), num(schema)]);
+ var chgEl = elOf(db, vcAcct(db, chg && chg.acct), d.absent, '{Charge.Expense}');
+ if (chgEl) d.add('DR', chgEl, -amt);
+ assetAmt -= -amt;
+ } else if (l.cashtype === 'D') { // Difference :198-205
+ var diffEl = cbEl('cb_differences_acct', '{CashBook.CashDifference}');
+ if (diffEl) d.add('DR', diffEl, -amt);
+ assetAmt += amt;
+ } else if (l.cashtype === 'I') { // Invoice :206-219
+ if (lineCur === docCur) assetAmt += amt;
+ else { var caEl = cbEl('cb_asset_acct', '{CashBook.CashAsset}'); if (caEl) d.add('DR', caEl, amt); }
+ var trEl = cbEl('cb_cashtransfer_acct', '{CashBook.CashTransfer}');
+ if (trEl) d.add('CR', trEl, amt); // amount.negate() → CR |amt|
+ } else if (l.cashtype === 'T') { // Transfer :220-236
+ var ba = getRow(db, 'SELECT * FROM c_bankaccount_acct WHERE c_bankaccount_id=? AND c_acctschema_id=?', [num(l.c_bankaccount_id), num(schema)]);
+ var itEl = elOf(db, vcAcct(db, ba && ba.b_intransit_acct), d.absent, '{BankAccount.InTransit}');
+ if (itEl) d.add('DR', itEl, -amt);
+ if (lineCur === docCur) assetAmt += amt;
+ else { var caEl2 = cbEl('cb_asset_acct', '{CashBook.CashAsset}'); if (caEl2) d.add('DR', caEl2, amt); }
+ }
+ });
+ if (assetAmt !== 0) { // header close :239-243
+ var assetEl = cbEl('cb_asset_acct', '{CashBook.CashAsset}');
+ if (assetEl) d.add(assetAmt > 0 ? 'DR' : 'CR', assetEl, Math.abs(assetAmt));
+ }
+ return d;
+}
+
+// Doc_Inventory.createFacts:211-513 (HARDEN_MATRIX.md §W-POST-TAIL-2), physical-inventory branch only
+// (this seed's docs are all DocSubTypeInv=PI). costs = the schema-costingmethod → cost-element →
+// m_cost.currentcostprice hop (same lookup as deriveProjectIssue); if costs resolves to 0 AND no
+// qualifying zero-cost-blessing M_CostDetail row exists (Doc_Inventory.java:319-336: Processed='Y',
+// Amt=0, Qty>0, from an order/invoice line), the REAL engine REFUSES the whole doc ("No Costs for
+// ") — createFacts returns null, ZERO fact rows, NOT a partial post. Verified live: product 147
+// (doc 100's only line) has currentcostprice=0 everywhere and ZERO m_costdetail rows → the REAL engine
+// refused doc 100 exactly this way (§TAILORACLE postErr="No Costs for TShirt - Red Large"). This manifest
+// reproduces that SAME refusal (0==0, a genuine match, not a vacuous one) and the falsifier flips the
+// blessing count to prove the gate — not the manifest — is what closes to ∅.
+function deriveInventory(db, id, schema) {
+ var hdr = getRow(db, 'SELECT * FROM m_inventory WHERE m_inventory_id=?', num(id));
+ if (!hdr) return null;
+ var lines = allRows(db, 'SELECT * FROM m_inventoryline WHERE m_inventory_id=? AND isactive=\'Y\'', num(id));
+ var d = b3New();
+ if (lines.length === 0) { d.absent.push('@NoLines@#' + id); return d; } // MInventory.prepareIt:401-406
+ lines.forEach(function (l) {
+ var as = schemaRow(db, schema);
+ var cost = getRow(db,
+ "SELECT c.currentcostprice AS p FROM m_cost c JOIN m_costelement e ON e.m_costelement_id=c.m_costelement_id" +
+ " AND e.costelementtype='M' AND e.costingmethod=? WHERE c.m_product_id=? AND c.c_acctschema_id=? AND c.m_costtype_id=?",
+ [as ? String(as.costingmethod) : '', num(l.m_product_id), num(schema), as ? num(as.m_costtype_id) : 0]);
+ var qtyDiff = Number(l.qtycount) - Number(l.qtybook); // PI branch :164-165
+ var costCents = cents(cost ? cost.p : 0);
+ if (costCents === 0) {
+ var bless = getRow(db,
+ "SELECT COUNT(*) AS n FROM m_costdetail WHERE m_product_id=? AND processed='Y' AND amt=0.00 AND qty>0" +
+ " AND (c_orderline_id>0 OR c_invoiceline_id>0)", num(l.m_product_id));
+ if (!bless || Number(bless.n) === 0) { d.absent.push('{Product.NoCosts}#' + l.m_product_id); return; } // :332-335 refusal
+ }
+ var amt = Math.round(costCents * qtyDiff);
+ var prod = getRow(db, 'SELECT producttype, m_product_category_id FROM m_product WHERE m_product_id=?', num(l.m_product_id));
+ var isService = prod && prod.producttype === 'S';
+ var pcol = isService ? 'p_expense_acct' : 'p_asset_acct';
+ var pacct = prod ? getRow(db, 'SELECT ' + pcol + ' AS acct FROM m_product_category_acct WHERE m_product_category_id=? AND c_acctschema_id=?', [num(prod.m_product_category_id), num(schema)]) : null;
+ var drEl = elOf(db, vcAcct(db, pacct && pacct.acct), d.absent, '{Product.' + (isService ? 'Expense' : 'Asset') + '}');
+ if (drEl) d.add('DR', drEl, amt);
+ // CR: line.getChargeAccount if C_Charge_ID≠0, else M_Warehouse_Acct.W_Differences_Acct (:1505-1509)
+ if (num(l.c_charge_id) > 0) {
+ var chg = getRow(db, 'SELECT ch_expense_acct AS acct FROM c_charge_acct WHERE c_charge_id=? AND c_acctschema_id=?', [num(l.c_charge_id), num(schema)]);
+ var chgEl = elOf(db, vcAcct(db, chg && chg.acct), d.absent, '{Charge.Expense}');
+ if (chgEl) d.add('CR', chgEl, amt);
+ } else {
+ var loc = getRow(db, 'SELECT m_warehouse_id FROM m_locator WHERE m_locator_id=?', num(l.m_locator_id));
+ var wa = loc ? getRow(db, 'SELECT w_differences_acct AS acct FROM m_warehouse_acct WHERE m_warehouse_id=? AND c_acctschema_id=?', [num(loc.m_warehouse_id), num(schema)]) : null;
+ var crEl = elOf(db, vcAcct(db, wa && wa.acct), d.absent, '{Warehouse.Differences}');
+ if (crEl) d.add('CR', crEl, amt);
+ }
+ });
+ return d;
+}
+
function finish(d, basis) {
if (!d) return { lines: [], balanced: false, sumDr: 0, sumCr: 0, absent: [], basis: 'none' };
var lines = Object.keys(d.by).map(function (k) {
@@ -450,6 +561,8 @@ function derivePostings(db, recordRef, schema, R) {
if (table === 'C_BankStatement') return finish(deriveBankStatement(db, id, schema), 'bank-statement');
if (table === 'M_MatchPO') return finish(deriveMatchPO(db, id, schema), 'matchpo');
if (table === 'M_Requisition') return finish(deriveRequisition(db, id, schema), 'requisition');
+ if (table === 'C_Cash') return finish(deriveCash(db, id, schema), 'cash');
+ if (table === 'M_Inventory') return finish(deriveInventory(db, id, schema), 'inventory');
return { lines: [], balanced: false, sumDr: 0, sumCr: 0, absent: [], basis: 'none' };
}
diff --git a/scripts/generate_post_tail_oracle.sh b/scripts/generate_post_tail_oracle.sh
new file mode 100755
index 000000000..884727af9
--- /dev/null
+++ b/scripts/generate_post_tail_oracle.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# Copyright (c) 2025-2026 Redhuan D. Oon
+# SPDX-License-Identifier: MIT
+# ⚠ DO NOT REMOVE — Scope guard
+# Scope: W-POST-TAIL §W-POST-TAIL-2 (prompts/HARDEN_MATRIX.md) — GENERATE the Cash/Inventory posting-tail
+# oracle. Twin of scripts/generate_post_oracle.sh (W-POST-B3 §W-2): clone idempiere_test → SCRATCH
+# idempiere_tail, drive the REAL compiled posters via the vendor's own OSGi test harness
+# (scripts/logic_oracle/PostingTailTest.java), capture the committed fact_acct + source rows into the
+# TEXT fixture build/erp/oracle/post_tail_fixture.json. UNLIKE B-3, this drives ZERO seed authoring —
+# every document posted/completed here already existed in idempiere_test (2 CO c_cash journals never
+# posted; 3 m_inventory drafts). The shared idempiere_test is NEVER written; scratch dropped at the end.
+# READ THE LOG after every run: build/erp/generate_post_tail_oracle.log (exit ≠ evidence).
+# Run: bash scripts/generate_post_tail_oracle.sh
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+CONTAINER=postgres
+PGUSER=adempiere
+SRC_DB=idempiere_test
+SCRATCH_DB=idempiere_tail
+IDMP="$HOME/idempiere-dev-setup/idempiere"
+TAIL_HOME=/tmp/idempiere_tail_home
+LOG=build/erp/generate_post_tail_oracle.log
+FIXTURE=build/erp/oracle/post_tail_fixture.json
+ORACLE_SRC=scripts/logic_oracle/PostingTailTest.java
+ORACLE_DST="$IDMP/org.idempiere.test/src/org/idempiere/test/oracle/PostingTailTest.java"
+# same workspace-bundle dep closure of org.idempiere.test as generate_post_oracle.sh (probed 2026-07-17):
+MODULES="org.idempiere.p2.targetplatform,org.apache.ecs,org.adempiere.base,org.adempiere.base.callout,org.adempiere.base.process,org.adempiere.payment.processor,org.adempiere.ui,org.adempiere.ui.zk,org.adempiere.report.jasper,org.adempiere.report.jasper.library,org.idempiere.zk.billboard,org.idempiere.zk.extra,org.adempiere.server,org.idempiere.webservices,org.idempiere.webservices.resources,org.compiere.db.postgresql.provider,org.compiere.db.oracle.provider,org.adempiere.install,org.adempiere.replication,org.adempiere.pipo,org.adempiere.pipo.handlers,org.adempiere.plugin.utils,org.idempiere.hazelcast.service,org.idempiere.tablepartition,org.idempiere.test"
+
+mkdir -p build/erp/oracle
+: > "$LOG"
+say() { echo "$@" | tee -a "$LOG"; }
+
+say "== §W-POST-TAIL-2 generate: scratch clone $SCRATCH_DB from $SRC_DB =="
+docker exec "$CONTAINER" psql -U "$PGUSER" -d postgres -c \
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN ('$SRC_DB','$SCRATCH_DB') AND pid<>pg_backend_pid();" >>"$LOG" 2>&1 || true
+docker exec "$CONTAINER" psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $SCRATCH_DB;" >>"$LOG" 2>&1
+docker exec "$CONTAINER" createdb -U "$PGUSER" -T "$SRC_DB" "$SCRATCH_DB"
+say "§GEN scratch=$SCRATCH_DB cloned (template $SRC_DB)"
+
+say "== scratch IDEMPIERE_HOME at $TAIL_HOME (Connection → $SCRATCH_DB) =="
+mkdir -p "$TAIL_HOME/utils" "$TAIL_HOME/log"
+sed "s/DBname\\\\=idempiere,/DBname\\\\=$SCRATCH_DB,/" "$IDMP/idempiere.properties" > "$TAIL_HOME/idempiere.properties"
+grep -q "DBname\\\\=$SCRATCH_DB" "$TAIL_HOME/idempiere.properties" || { say "§GEN FATAL properties patch failed"; exit 1; }
+cp "$IDMP/org.adempiere.server-feature/utils.unix/getVar.sh" "$TAIL_HOME/utils/"
+chmod +x "$TAIL_HOME/utils/getVar.sh"
+cp "$IDMP/.idpass" "$TAIL_HOME/.idpass"
+sed "s/^ADEMPIERE_DB_NAME=.*/ADEMPIERE_DB_NAME=$SCRATCH_DB/" "$IDMP/idempiereEnv.properties" > "$TAIL_HOME/idempiereEnv.properties"
+
+say "== place PostingTailTest into the vendor harness (removed again at the end) =="
+mkdir -p "$(dirname "$ORACLE_DST")"
+cp "$ORACLE_SRC" "$ORACLE_DST"
+
+say "== drive the REAL compiled posters (tycho-surefire OSGi, scratch DB) — long step =="
+set +e
+( cd "$IDMP" && ./mvnw verify -pl "$MODULES" -DskipTests=false \
+ -Didempiere.home="$TAIL_HOME" -Dtest=PostingTailTest ) >>"$LOG" 2>&1
+MVN_EXIT=$?
+set -e
+rm -f "$ORACLE_DST"; rmdir "$(dirname "$ORACLE_DST")" 2>/dev/null || true
+grep '§TAILORACLE' "$LOG" | tee build/erp/oracle/post_tail_oracle_run.log || true
+if [ $MVN_EXIT -ne 0 ]; then
+ say "§GEN FATAL mvn exit=$MVN_EXIT — READ $LOG (posting did not complete; scratch kept for autopsy)"
+ exit 1
+fi
+say "§GEN posting run green (mvn exit 0) — capturing fixture"
+
+say "== capture fixture (scoped to C_Cash/M_Inventory) =="
+node scripts/capture_post_tail_fixture.js "$SCRATCH_DB" "$FIXTURE" | tee -a "$LOG"
+
+say "== drop scratch =="
+docker exec "$CONTAINER" psql -U "$PGUSER" -d postgres -c \
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$SCRATCH_DB' AND pid<>pg_backend_pid();" >>"$LOG" 2>&1 || true
+docker exec "$CONTAINER" psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $SCRATCH_DB;" >>"$LOG" 2>&1
+say "§GEN scratch dropped; fixture at $FIXTURE — now run: bash build/erp/run_witness.sh scripts/poc_post_tail.js"
diff --git a/scripts/logic_oracle/PostingTailTest.java b/scripts/logic_oracle/PostingTailTest.java
new file mode 100644
index 000000000..1fed2e18d
--- /dev/null
+++ b/scripts/logic_oracle/PostingTailTest.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2025-2026 Redhuan D. Oon
+ * SPDX-License-Identifier: MIT
+ *
+ * PostingTailTest.java — ORACLE side of W-POST-TAIL §W-POST-TAIL-2 (prompts/HARDEN_MATRIX.md).
+ * Sibling of PostingOracleTest.java (W-POST-B3): same vendor OSGi test harness, same scratch-clone
+ * drive-capture-drop machinery (scripts/generate_post_tail_oracle.sh), but ZERO seed authoring — every
+ * document driven here is a REAL row that already existed in idempiere_test (2 CO c_cash journals never
+ * posted; 3 m_inventory drafts, of which 2 have ZERO lines and cannot even complete). This test posts/
+ * completes the REAL rows, nothing more.
+ *
+ * TARGET DB: the SCRATCH clone idempiere_tail (createdb -T idempiere_test), never idempiere_test itself.
+ * COMMITS into the scratch clone only (AbstractTestCase default is rollback).
+ *
+ * C_CASH (407): c_cash_id=100 (1 line, CashType=Invoice) and c_cash_id=101 (3 lines: Expense/Transfer/
+ * Receipt) are ALREADY DocStatus=CO, Posted=N — no processIt(Complete) call (that transition is invalid
+ * from an already-completed document); just drive DocManager.postDocument directly, the same real
+ * posting entrypoint postImmediate/AcctProcessor call.
+ *
+ * M_INVENTORY (321): m_inventory_id=100 has 1 real line (product 147, qtycount=1/qtybook=0) and is the
+ * only completable draft. m_inventory_id=200000/200001 have ZERO lines each (verified live 2026-07-18) —
+ * MInventory.prepareIt (MInventory.java:401-406) rejects 0-line docs with "@NoLines@" BEFORE completion.
+ * This test drives processIt(Complete) on all 3 and expects exactly that outcome: 100 completes (posting
+ * outcome logged honestly either way — cost data for product 147 is 0 everywhere in this seed, so the
+ * REAL engine may itself refuse to post with "No Costs for ..." per Doc_Inventory.java:319-336; that is
+ * an honest finding, not a test bug), 200000/200001 fail with the engine's own @NoLines@ error. NON-INVENT:
+ * nothing is seeded onto the 0-line drafts to make them completable — that would be authoring source data
+ * on someone else's existing document, out of scope for the seed-prep ruling (which covers NEW documents).
+ */
+package org.idempiere.test.oracle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.util.Properties;
+
+import org.compiere.acct.DocManager;
+import org.compiere.model.MAcctSchema;
+import org.compiere.model.MCash;
+import org.compiere.model.MInventory;
+import org.compiere.process.DocAction;
+import org.compiere.util.DB;
+import org.compiere.util.Env;
+import org.idempiere.test.AbstractTestCase;
+import org.junit.jupiter.api.Test;
+
+public class PostingTailTest extends AbstractTestCase {
+
+ private void say(String s) { System.out.println("§TAILORACLE " + s); }
+
+ @Test
+ public void tailPostingOracle() {
+ Properties ctx = Env.getCtx();
+ String trxName = getTrxName();
+
+ String dbName = DB.getSQLValueStringEx(trxName, "SELECT current_database()");
+ say("db=" + dbName);
+ assertEquals("idempiere_tail", dbName, "PostingTailTest must run on the scratch clone idempiere_tail");
+
+ // ── C_Cash: post the 2 REAL already-CO journals directly (no re-complete) ──────────────────
+ postExistingCash(ctx, 100, trxName);
+ postExistingCash(ctx, 101, trxName);
+
+ // ── M_Inventory: drive processIt(CO) on all 3 real docs; 100 is the only completable one ──
+ tryCompleteInventory(ctx, 100, trxName, false);
+ tryCompleteInventory(ctx, 200000, trxName, true);
+ tryCompleteInventory(ctx, 200001, trxName, true);
+
+ // ── fact tallies from inside the trx (external capture re-reads post-commit) ───────────────
+ int cashFacts = DB.getSQLValueEx(trxName, "SELECT COUNT(*) FROM Fact_Acct WHERE AD_Table_ID=407");
+ int invFacts = DB.getSQLValueEx(trxName, "SELECT COUNT(*) FROM Fact_Acct WHERE AD_Table_ID=321");
+ say("fact_count table=C_Cash ad_table_id=407 rows=" + cashFacts);
+ say("fact_count table=M_Inventory ad_table_id=321 rows=" + invFacts);
+
+ commit();
+ say("committed=Y db=" + dbName);
+ }
+
+ /** Post an already-CO MCash doc via the real posting entrypoint (no re-complete). */
+ private void postExistingCash(Properties ctx, int id, String trxName) {
+ MCash cash = new MCash(ctx, id, trxName);
+ assertEquals(id, cash.get_ID(), "MCash " + id + " did not load");
+ String preStatus = cash.getDocStatus();
+ assertEquals("CO", preStatus, "MCash " + id + " expected pre-existing DocStatus=CO");
+
+ // Doc.postIt's lock UPDATE (Doc.java:591-605) requires Processed='Y' AND IsActive='Y' — if the
+ // row itself is IsActive='N' (verified live 2026-07-18: BOTH c_cash 100/101 carry IsActive='N'
+ // in this seed), the lock never fires and DocManager returns "CannotPostInactiveDocument". This
+ // is an engine-enforced block on the row's own data state — the SAME category as M_Inventory's
+ // @NoLines@ below: NOT worked around (flipping IsActive would be mutating someone else's
+ // document, out of the seed-prep ruling's scope), named honestly and skipped.
+ if (!cash.isActive()) {
+ say("class=C_Cash record_id=" + id + " isactive=N — SKIPPED by design (CannotPostInactiveDocument gate, Doc.java:605)");
+ return;
+ }
+
+ MAcctSchema[] ass = MAcctSchema.getClientAcctSchema(ctx, cash.getAD_Client_ID());
+ String postErr = DocManager.postDocument(ass, MCash.Table_ID, id, true, true, trxName);
+ cash.load(trxName);
+ Object rawPosted = cash.get_Value("Posted");
+ boolean posted = "Y".equals(rawPosted) || Boolean.TRUE.equals(rawPosted);
+ say("class=C_Cash record_id=" + id + " docstatus=" + cash.getDocStatus()
+ + " posted=" + (posted ? "Y" : "N") + " rawPosted=" + rawPosted
+ + (postErr != null ? " postErr=" + postErr : ""));
+ assertTrue(posted, "MCash " + id + " not posted" + (postErr != null ? " — " + postErr : ""));
+ commit(); // scratch clone: null-trx readers must see this before the external capture
+ }
+
+ /**
+ * Drive processIt(Complete) on an MInventory draft. expectNoLines=true asserts the engine's own
+ * @NoLines@ rejection (0-line doc, cannot complete); otherwise asserts completion succeeded and
+ * attempts the real posting entrypoint, logging the outcome honestly either way (a "No Costs"
+ * refusal from Doc_Inventory.createFacts is a valid, named, non-invented result here).
+ */
+ private void tryCompleteInventory(Properties ctx, int id, String trxName, boolean expectNoLines) {
+ MInventory inv = new MInventory(ctx, id, trxName);
+ assertEquals(id, inv.get_ID(), "MInventory " + id + " did not load");
+ DocAction doc = (DocAction) inv;
+ boolean ok;
+ String threw = null;
+ try { ok = doc.processIt(DocAction.ACTION_Complete); }
+ catch (Exception e) { ok = false; threw = e.getMessage(); }
+ inv.saveEx();
+ inv.load(trxName);
+ String docStatus = inv.getDocStatus();
+ String procMsg = doc.getProcessMsg();
+ say("class=M_Inventory record_id=" + id + " processIt_ok=" + ok + " docstatus=" + docStatus
+ + " processMsg=" + procMsg + (threw != null ? " threw=" + threw : ""));
+
+ if (expectNoLines) {
+ assertFalse(ok, "MInventory " + id + " expected to fail (@NoLines@) but processIt returned true");
+ assertTrue(procMsg != null && procMsg.contains("NoLines"),
+ "MInventory " + id + " expected @NoLines@, got: " + procMsg);
+ return; // by design: no posting attempt on a doc that never completed
+ }
+
+ assertTrue(ok, "MInventory " + id + " processIt(CO) failed: " + procMsg);
+ assertEquals(DocAction.STATUS_Completed, docStatus, "MInventory " + id + " not completed");
+ commit();
+
+ Object rawPosted = inv.get_Value("Posted");
+ boolean posted = "Y".equals(rawPosted) || Boolean.TRUE.equals(rawPosted);
+ String postErr = null;
+ if (!posted) {
+ MAcctSchema[] ass = MAcctSchema.getClientAcctSchema(ctx, inv.getAD_Client_ID());
+ postErr = DocManager.postDocument(ass, MInventory.Table_ID, id, true, true, trxName);
+ inv.load(trxName);
+ rawPosted = inv.get_Value("Posted");
+ posted = "Y".equals(rawPosted) || Boolean.TRUE.equals(rawPosted);
+ }
+ say("class=M_Inventory record_id=" + id + " posted=" + (posted ? "Y" : "N") + " rawPosted=" + rawPosted
+ + (postErr != null ? " postErr=" + postErr : "") + " (an honest post-refusal here is a named result, not a test failure)");
+ commit();
+ }
+}
diff --git a/scripts/poc_post_tail.js b/scripts/poc_post_tail.js
index 7facf11f8..c2b26011a 100644
--- a/scripts/poc_post_tail.js
+++ b/scripts/poc_post_tail.js
@@ -99,7 +99,76 @@ console.log('§TAIL-POST class=M_Requisition docs=' + reqs.length + ' oracle_row
verdict(reqs.length === 1 && reqFacts === 0 && reqNonEmpty === 0,
'M_Requisition: engine ∅ == the REAL engine\'s ∅ (reservation gate closed)');
-// ── 4. §FALSIFIERS (load-bearing) on a THROWAWAY copy ──────────────────────────────────────────────
+// ── 4. C_Cash (407) / M_Inventory (321) — REAL scratch-clone run (generate_post_tail_oracle.sh):
+// both classes' real documents hit ENGINE-ENFORCED data-state blockers, distinct from B-3's seed
+// generation and from MatchPO/Requisition's CONFIG gates. NON-INVENT: neither blocker is worked
+// around (flipping IsActive or seeding lines onto an existing doc would be mutating someone else's
+// document, out of the seed-prep ruling's scope — same boundary the card itself draws for Inventory).
+console.log('\n── C_Cash (407) / M_Inventory (321) — REAL engine run, both ∅-by-data-state ──');
+var fs = require('fs');
+var TAIL_FIXTURE = path.join(__dirname, '..', 'build', 'erp', 'oracle', 'post_tail_fixture.json');
+if (!fs.existsSync(TAIL_FIXTURE)) {
+ console.log('§TAIL-SKIP tail fixture absent (' + TAIL_FIXTURE + ') — run: bash scripts/generate_post_tail_oracle.sh');
+ fails++;
+} else {
+ var tfx = JSON.parse(fs.readFileSync(TAIL_FIXTURE, 'utf8'));
+ var tdb = new Database(':memory:');
+ Object.keys(tfx.tables).forEach(function (t) {
+ var spec = tfx.tables[t];
+ tdb.prepare('CREATE TABLE ' + t + ' (' + spec.cols.join(',') + ')').run();
+ var ins = tdb.prepare('INSERT INTO ' + t + ' VALUES (' + spec.cols.map(function () { return '?'; }).join(',') + ')');
+ spec.rows.forEach(function (r) { ins.run(r.map(function (v) { return /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v; })); });
+ });
+
+ // §TAIL-POST2 re-assert: the REAL engine (scratch-clone, generate_post_tail_oracle.sh) wrote ZERO
+ // fact_acct rows for either class — read from the captured oracle, not assumed.
+ var cashOracleRows = tdb.prepare('SELECT count(*) c FROM fact_acct WHERE ad_table_id=407').get().c;
+ var invOracleRows = tdb.prepare('SELECT count(*) c FROM fact_acct WHERE ad_table_id=321').get().c;
+
+ // C_Cash: both real docs are IsActive='N' (re-read from the captured oracle, not the Java log alone)
+ var cashRows = tdb.prepare('SELECT c_cash_id, isactive FROM c_cash ORDER BY c_cash_id').all();
+ var cashInactive = cashRows.filter(function (r) { return r.isactive === 'N'; });
+ console.log('§TAIL-POST2 C_Cash docs=' + cashRows.length + ' isactive_N=' + cashInactive.length +
+ ' oracle_rows=' + cashOracleRows + ' basis=∅-by-data-state (Doc.java:591-605 CannotPostInactiveDocument)');
+ verdict(cashRows.length === 2 && cashInactive.length === 2 && cashOracleRows === 0,
+ 'C_Cash: both real CO docs are IsActive=N → the REAL engine\'s lock-UPDATE never fires, ∅ oracle confirmed',
+ 'docs=' + cashRows.map(function (r) { return r.c_cash_id; }).join(','));
+ // falsifier: the manifest itself is LIVE — it computes REAL non-empty legs from the real line data,
+ // proving the ∅ is the IsActive gate (outside createFacts), not a dead/no-op verb or a manifest bug.
+ var cashLive0 = DP.derivePostings(tdb, { table: 'C_Cash', id: 100 }, 101);
+ var cashLive1 = DP.derivePostings(tdb, { table: 'C_Cash', id: 101 }, 101);
+ var cashLiveOk = cashLive0.lines.length > 0 && cashLive1.lines.length > 0;
+ verdict(cashLiveOk, '§FALSIFIER C_Cash manifest is LIVE (non-empty legs from the real line data) — the ∅ is IsActive-gate-derived, not dead',
+ 'doc100 lines=' + cashLive0.lines.length + ' doc101 lines=' + cashLive1.lines.length);
+ console.log('§TAIL-FALSIFIER cash-live doc100_lines=' + cashLive0.lines.length + ' doc101_lines=' + cashLive1.lines.length + ' (must be >0)');
+
+ // M_Inventory: 2/3 never even complete (@NoLines@); the 1 completable doc (100) hits the REAL
+ // engine's own "No Costs" refusal (Doc_Inventory.java:319-336) — product 147 has zero cost data.
+ var invNoLines = tdb.prepare('SELECT m_inventory_id FROM m_inventory WHERE m_inventory_id IN (200000,200001)').all();
+ var inv100Lines = tdb.prepare('SELECT count(*) c FROM m_inventoryline WHERE m_inventory_id=100').get().c;
+ var costRow = tdb.prepare("SELECT currentcostprice FROM m_cost WHERE m_product_id=147 AND currentcostprice<>0").all();
+ var blessRows = tdb.prepare("SELECT count(*) c FROM m_costdetail WHERE m_product_id=147 AND processed='Y' AND amt=0.00 AND qty>0 AND (c_orderline_id>0 OR c_invoiceline_id>0)").get().c;
+ console.log('§TAIL-POST2 M_Inventory noLines_docs=' + invNoLines.length + ' doc100_lines=' + inv100Lines +
+ ' nonzero_cost_rows=' + costRow.length + ' zero_cost_blessing_rows=' + blessRows +
+ ' oracle_rows=' + invOracleRows + ' basis=∅-by-data-state (MInventory.java:401-406 @NoLines@ + Doc_Inventory.java:319-336 No-Costs)');
+ verdict(invNoLines.length === 2 && inv100Lines === 1 && costRow.length === 0 && blessRows === 0 && invOracleRows === 0,
+ 'M_Inventory: 2 docs cannot even complete (@NoLines@), the 1 completable doc has zero cost data anywhere → ∅ oracle confirmed',
+ 'noLines=' + invNoLines.map(function (r) { return r.m_inventory_id; }).join(','));
+ // falsifier: the manifest matches the SAME "No Costs" refusal (0==0, not vacuous — flip the blessing
+ // count in an in-memory copy and the gate opens, a real DR/CR pair appears).
+ var invReal = DP.derivePostings(tdb, { table: 'M_Inventory', id: 100 }, 101);
+ var invRealOk = invReal.lines.length === 0 && invReal.absent.some(function (a) { return String(a).indexOf('NoCosts') >= 0; });
+ verdict(invRealOk, 'M_Inventory doc 100: manifest ALSO refuses (0 lines, {Product.NoCosts} named absent) — matches the real engine\'s refusal exactly',
+ 'lines=' + invReal.lines.length + ' absent=[' + invReal.absent.join(',') + ']');
+ tdb.prepare("INSERT INTO m_costdetail (m_costdetail_id,m_product_id,processed,amt,qty,c_orderline_id,c_invoiceline_id) VALUES (999,147,'Y',0.00,1,500,0)").run();
+ var invFlip = DP.derivePostings(tdb, { table: 'M_Inventory', id: 100 }, 101);
+ var invFlipOk = invFlip.lines.length > 0 || invFlip.absent.some(function (a) { return String(a).indexOf('NoCosts') < 0; });
+ verdict(invFlipOk, '§FALSIFIER M_Inventory blessing-flip: adding a qualifying zero-cost M_CostDetail row opens the gate (0 → a real DR/CR pair at cost=0)',
+ 'lines=' + invFlip.lines.length + ' absent=[' + invFlip.absent.join(',') + ']');
+ console.log('§TAIL-FALSIFIER inv-bless-flip doc=100 before_lines=' + invReal.lines.length + ' after_lines=' + invFlip.lines.length + ' (must open)');
+}
+
+// ── 5. §FALSIFIERS (load-bearing) on a THROWAWAY copy ──────────────────────────────────────────────
console.log('\n── §FALSIFIERS (in-memory copy; the oracle db stays read-only) ──');
var mem = new Database(':memory:');
['c_acctschema', 'c_acctschema_gl', 'c_bankstatement', 'c_bankstatementline', 'c_bankaccount_acct',
@@ -150,14 +219,25 @@ verdict(gotDr !== wantDr, '§FALSIFIER scale-one-line: doubling stmtamt breaks t
console.log('§TAIL-FALSIFIER bs-scale line=100 oracleΣDR=' + wantDr + 'c scaledΣDR=' + gotDr + 'c (must differ)');
mem.close();
-// ── 5. named skips (the honest remainder) ──────────────────────────────────────────────────────────
-console.log('\n§TAIL-SKIPS C_Cash (407): 2 real CO docs NEVER posted (posted=N, 0 facts) → post them on a scratch');
-console.log('§TAIL-SKIPS clone via the B-3 generator (zero seed prep) — NEXT session, not claimed here.');
-console.log('§TAIL-SKIPS M_Inventory (321): 3 real DRAFTS → complete+post on the clone — NEXT session.');
+// ── 6. named ⛔ (the honest remainder, all empirically driven on a scratch clone — nothing assumed) ──
+console.log('\n§TAIL-SKIPS C_Cash (407): drove the REAL engine on scratch idempiere_tail (generate_post_tail_oracle.sh)');
+console.log('§TAIL-SKIPS over the 2 real CO docs — BOTH are IsActive=N, DocManager.postDocument refuses');
+console.log('§TAIL-SKIPS ("CannotPostInactiveDocument", Doc.java:591-605) → ⛔ BY THE ENGINE\'S OWN ERROR.');
+console.log('§TAIL-SKIPS NOT worked around: flipping IsActive on an existing document is out of the');
+console.log('§TAIL-SKIPS seed-prep ruling\'s scope (same boundary as the Inventory @NoLines@ docs below).');
+console.log('§TAIL-SKIPS M_Inventory (321): drove processIt(CO) on all 3 real drafts on the same scratch —');
+console.log('§TAIL-SKIPS 200000/200001 have ZERO lines each, MInventory.prepareIt refuses BEFORE completion');
+console.log('§TAIL-SKIPS (@NoLines@, MInventory.java:401-406); doc 100 (1 line, product 147) DOES complete');
+console.log('§TAIL-SKIPS but the REAL engine then REFUSES to post it ("No Costs for TShirt - Red Large",');
+console.log('§TAIL-SKIPS Doc_Inventory.java:319-336 — product 147 has currentcostprice=0 everywhere and');
+console.log('§TAIL-SKIPS zero M_CostDetail rows, so the zero-cost-blessing check fails) → ⛔, 0/3 posted.');
console.log('§TAIL-SKIPS M_Production (325): 0 docs + component m_cost absent (W-FOLD-PRODUCTION deferral) → ⛔');
console.log('§TAIL-SKIPS until a costed BOM seed exists; costs are NOT synthesized.');
+console.log('§TAIL-SKIPS Matrix ledger stays 52 oracle-equivalent / 17-of-20 posters — Cash+Inventory join');
+console.log('§TAIL-SKIPS Production as named ⛔, each for a DIFFERENT, precisely-cited engine-level reason.');
db.close();
console.log('\n' + (fails === 0 ? '🟢 W-POST-TAIL PASS' : '🔴 W-POST-TAIL FAIL (' + fails + ')') +
- ' — BankStatement folds the REAL journal to the cent; MatchPO(37)+Requisition ∅==∅ config-derived; 3 named skips.');
+ ' — BankStatement folds the REAL journal to the cent; MatchPO(37)+Requisition ∅==∅ config-derived;' +
+ ' Cash+Inventory ∅==∅ data-state-derived (REAL scratch-clone run); 3 named ⛔ (Cash/Inventory/Production).');
process.exit(fails === 0 ? 0 : 1);