Skip to content

/* * BIM OOTB / ERP OOTB — The Holy Grail: editable business rules, live. * Copyright (c) 2025-2026 Redhuan D. Oon red1org@gmail.com * SPDX-License-Identifier: MIT /

The Holy Grail — Editable Business Rules, Live

See also: the one-page evaluator companion — Migrate & Compare (ERP) (legacy ERP vs the WASM event-sourced browser); the back-up-the-recipe §below is linked from its backup figure.

For a retail owner: the plain-English, animated one-pager — Two messages a day → books to the penny — proves the same engine at 10,000 tills (benchmark W-POS-WAN-SCALE, runnable).

A first-person note from the author. The technical claims below are grounded in the dated, witnessed sections of ERP.md; this page is the reasoning that ties them to a quest I have carried for a long time.

Who is writing this, and why it is personal

I am Redhuan D. Oon. I was the founding leader of ADempiere, and in that role I materially ushered in the birth of iDempiere — the community, the people, and the direction that made it possible. I then left that path to pursue this one.

I did not leave because the open-source ERP idea was wrong. I left because the thing I most wanted from it — rules you can edit while the system runs, safely, without a build — was structurally out of reach inside the architecture we had built. For two years I tried to reach it from the inside: I attempted, with Spring and plain Java, to extract even the core of the model — PO.java, Info.java — into something light enough to re-host and re-open. It did not converge. This document is the account of why it could not, and why a browser-and-PWA paradigm shift reached it from the outside — something that genuinely surprised me, the person who had spent the most time failing at it the other way.

What the Holy Grail actually is

Not "an ERP in a browser." That is a means. The grail is one specific capability:

Edit a business rule, and watch the affected records change on the map — live, reversibly, with no recompilation and no server.

In this project that is named directly: the parked endgame in ERP.md "Why this is more than a port""let you edit a rule and watch the affected records flip on that map (the diff-oracle in the browser, §2d-3)" — and in GLASSBOWL_DOSSIER.md as the final big picture: Glassbowl stops being a map of the engine and becomes the console you run the engine from.

It is earmarked precisely, not vaguely:

Earmark What it fixes
ERP.md §0.4Editable business rules, the SystemAdmin role names this as the differentiator, not a feature
ERP.md §0.5the rules engine = a per-cell decision table the shape: a table per (DocType, status, action) cell — not Rete / DSL / inference
ERP.md §0.9the rule mechanism, JSR-223-native the host language is JavaScript, so the rule language = the runtime language; the scripting-engine abstraction is unnecessary
ERP.md §0.10the Rule Compiler the rules are already extracted to data: erp_rules.db, 746 records, diff-verified
ERP.md §2d-3 / GLASSBOWL_DOSSIER.md the live edit-and-reflow loop — the grail itself

Why two years of extracting PO.java was the wrong target

This is the part I most want a future reader — especially one still inside a code-engine ERP — to understand, because it cost me the most to learn.

PO.java is not extractable, and it never was. Not because it is hard, but because it is the wrong thing to extract. It is an imperative engine. Pull on it and the entire transitive graph follows: the model registry, the OSGi service wiring, the transaction manager, the callout chain, the MTable/MColumn reflection. You are trying to lift the engine with its whole gravity well attached. Two years is simply what that costs — and it does not converge, however much effort you add.

The paradigm shift is not "do the extraction better in a PWA." It is to stop extracting the engine, and extract what the engine operates on:

  • The Application Dictionary rows and the rule records were already dataAD_Rule, AD_Val_Rule. That is iDempiere's own design (ERP.md §0.9); half the "rules engine" already exists as data. We compile it, we do not reinvent it.
  • The remainder — the deterministic part that actually runs a document — is small. Re-hosted as a ~150-line kernel in the browser's native JavaScript, PO.java's job (persist + lifecycle) becomes apply(op) plus the op-log fold; Info.java's job (the windowed UI) becomes the keyed-overlay Application Dictionary (the UI-overlay governance model — every UI concern a keyed layer over a tagged element).

The bloat was not ported. It was deleted by being re-based. That is why the shift worked from the outside when extraction failed from the inside.

So — are we free of PO.java?

Two senses, and they differ. From running it: yes, completely. Nothing in the browser instantiates or extends PO.java; each of its jobs is re-based onto data or the log — generic save → apply(op) + the 5-table fold (no UPDATE-in-place exists at all); change-tracking → kernel_ops before/after + lineage; beforeSave/mandatory validation → rules-as-data (AD_Val_Rule); get_ID → edge-minted UUID recorded as input (§0.21); model-validator firing → the named-handler registry; trx → the op-group, the document-event as the atomic unit (§18.8). Witnessed: replay reproduces the O2C/P2P oracle to the cent with zero iDempiere code executing.

The freedom came by never extracting it. Carrying PO.java somewhere lighter never converges — you drag its whole gravity well. But PO.java's defining property, generic and metadata-driven, is precisely what makes it deletable: anything fully driven by metadata is replaceable by reading the metadata and folding the log. iDempiere could not take that step because that core was welded to the JVM / OSGi / trx / side-effects; remove the weld and PO.java has nothing left to do.

From what its subclasses knew: deliberately not — and that is correct. The M*.*It() methods remain the oracle we verify against, per cell, so "extract" never slides into "guess." The day that consultation ends is the day every cell is extracted and verified — the §0.17 breadth campaign, in flight (O2C/P2P done; GL still dataless; the DocAction corpus being abstracted now). Free of running it; finishing the extraction of what it taught the models is the campaign, not a new idea.

Why it is a grail others cannot reach

Three conditions have to hold at the same time. Almost no system has all three — which is exactly why this remained a quest rather than a checkbox:

  1. The engine is data. A rule is a row you read, not Java you recompile.
  2. The host language is the rule language. Browser JavaScript is the runtime, so editing a rule is editing the running system — no JSR-223, no Drools workbench, no scripting bridge (ERP.md §0.9).
  3. The op-log makes editing safe. Change-as-op, replay, dry-run, undo — the safety Drools never had (ERP.md §0.4). You can edit a rule and not corrupt history, because effects are frozen and replayable (ERP.md §0.18).

iDempiere has condition (1) only half-done and lacks (2) and (3): its rules are half-data, half-welded into M* Java side effects, so they can never be made fully editable-at-runtime without the JVM / OSGi / workbench that constitute the bloat. Drools has a rule engine but no op-log safety and no engine-as-data. For them this is a stuck problem; here it is a structural property. The bloat that prevents them is precisely the bloat this project removed.

How every ERP's logic enters (the generalize / decision-data / caged-plugin question, and why a Drools-like affordance is kept but the Drools engine is not) → the six-layer logic-admission model in IDEMPIERE_2.md. The grail is that model's L1/L2/L5 made live.

The honest status — half-claimed, one mile left

I will not overclaim my own grail. It is half-reached, and witnessed that far:

  • Reached: the rules are extracted to data — erp_rules.db, 746 records, diff-verified against the GardenWorld oracle (ERP.md §0.10, §0.17). The engine renders itself from that data (Glassbowl), read-only.
  • Remaining: the live edit loopedit the rule → records reflow on the map — is the write-loop, gated behind T3 (push=live, explicit go). The read-only History undo-preview and the greyed CRUD ring are the honest seam to it, not the step.

And the seam is now being built. The CRUD / Validation overlay (the "ring-of-fire" edit layer, governed by the UI-overlay model) carries the AD_Val_Rule model as a keyed, editable layer. Make that validation layer editable and re-folding instead of static, and the grail is demonstrable in one gesture. The single witness that closes it:

§RULE-EDIT key=c_invoice rule=non_negative_amt edit=min:0→min:100
           affected=K records re-fold verify=ok reversible=Y

Change one validation row; watch K records change which side of the rule they fall on, live; op-logged, signed, reversible. That witness is the whole paradigm shift made visible in a single gesture — and the architecture it needs (engine-as-data + a JavaScript host + the signed op-log) is already standing under it.

Roadmap check — the write-seam, as of 2026-06-13

The grail is the last rung of the write-loop, not a separate project. Here is the ladder from today's read-only surface to the live rule-edit, and where each rung stands — this section is meant to be updated as a checkpoint each time a rung is climbed.

Rung What it is State
R0 Rules extracted to data, diff-verified (erp_rules.db, 746 records) done — witnessed
R1 The engine renders itself from that data, read-only (Glassbowl) done — live
E2 The write seam — CRUD ring + the document state machine (Process / DocAction) modelled as keyed data, dry-run done — witnessed (this checkpoint)
E3 The seam goes live — applycommitOp/sealChain, verifyChain after each; projection re-folds; acceptance oracle (rebuilt #80001 == traced #80001) next
E4 Owner-gate + CAS invoked on the write path (the Phase-A guards surfaced) pending
§RULE-EDIT The grail — edit a rule row; watch K records re-fold live, signed + reversible the last rung

What just landed, and why it matters to the grail. The CRUD + Process (DocAction) overlay (E2) is now mounted in tandem with the Help guide as an independent peer layer — witnessed §CRUD-PROC pass=27 fail=0, plus the earlier CRUD validation/dry-run witnesses, all still dry-run. The Process piece is the part that matters here: it models the document's state machinecompleteIt(), the legality of DR→CO, the IP-on-unmet outcome — as data (docAction descriptors naming the real M*.completeIt() oracles), not code. That widens the grail's surface from field-validation rules to lifecycle rules: the most valuable rule a user edits is usually "when may this complete, and what does completion do" — and that is now keyed data the same edit loop can reach. The grail is no longer only "edit a minimum amount"; it is "edit when an invoice may post."

The honest gap is unchanged. E2 is still dry-run — it logs the op it would run. The load-bearing step is E3: the signed write plus the acceptance oracle that proves a re-built document matches the traced one. Until E3 is green, the seam is a faithful model of the engine, not the live engine; and the grail rung (§RULE-EDIT) sits one step past E3/E4. So the checkpoint verdict, stated plainly: the seam to the grail is now built and witnessed as data; the current is not yet flowing through it. A rung climbed, honestly logged — which is the difference between building and day-dreaming.

2026-06-13 checkpoint — R0 extended to phrase authoring. The POS deliver-later variant (W-POS-DELIVERLATER) and the NinjaExcel reporting engine (W-NINJA, W-NINJA-RULE) have landed. The grail-relevant piece is W-NINJA-RULE: a phrase-to-SQL compiler that resolves human phrases ("COUNT of Invoices, completed, is Sales Transaction, from …") to SQL candidates using only vocabulary extracted from the AD dictionary — ad_table / ad_column / ad_ref_list — zero LLM, zero hardcoded strings. The browser pill (W-NINJA-RULE-UI) runs the same compile → sample-falsify → human-picks-tie loop live. This extends R0 from "rules extracted to data and diff-verified" into "rules authorable from human phrases in the browser, using the AD as vocabulary" — the write side of the same foundation the grail requires. The gap to E3/§RULE-EDIT is unchanged; the authoring story for the rules you will eventually edit is now demonstrated.

Abstracting the DocAction corpus — and why it is the migration solvent

How much of the corpus is folded today → ERP Coverage Matrix: of the 14 DocActions × 52 C_DocTypes, the engine interprets only CO (Complete) — the other 13 actions and the per-doctype FSM are a ⛔ gap. The de-interleaved transition table is also the storage opcode table (poc_oc_bytes.js §OPCODE-TABLE).

2026-06-11 — the recipe runs LIVE at a point of sale: the POS addon (POS_ADDON_SPEC) dispatches the WR (on-the-fly shipment+invoice) DocAction recipe as ONE signed op group on idempiere.html — order→CO + ship + invoice + BOM backflush + replenishment fold, all existing verbs (W-POS-WR: replay-equal to the engine's own specs; tamper breaks verifyChain; the invoice posting == real fact_acct(318) to the cent).

The grail edits rules; the most valuable rules govern a document's lifecyclewhen may this complete, and what does completion do. So the engine must express the whole iDempiere DocAction corpus (≈13 actions — DR/PR/CO/AP/RJ/CL/RC/RA/VO/RE/IN/XL/PO — over ≈13 statuses) as data, not code, without re-bloating into per-model logic. It does, because DocumentEngine is already shared code in iDempiere, not per-model — the tell that a state machine is hiding inside a switch. It abstracts into three layers, and only one is per-model:

Layer What it is Cost
The transition table (DocStatus × DocAction) → {legal?, nextStatus, guard, handler} — one generic, sparse decision table (§0.5) extracted once, model-agnostic
The handler families the side effects — and the corpus collapses (below) a small closed set
The oracle each docAction names its M*.<action>It(); the diff-oracle proves the generic handler reproduces it, cell by cell verify, never port

The corpus collapses — but by de-interleaving, not by being secretly empty. MOrder.completeIt() and MInvoice.completeIt() genuinely run for pages. They are long because each interleaves four concerns in one imperative method — and the same blob is re-written in MOrder, MInvoice, MInOut, MPayment. Separate the four and the pages shrink, because three of them are written once for the whole corpus:

Concern tangled inside the method What it is Where it goes Written
setDocStatus/Processed/DocAction, validate hooks status bookkeeping (boilerplate in every method) the transition table once
mandatory fields, credit limit, legal status guards — predicates (§0.14 GUARD) rules (data) once, shared
createShipment, createInvoice, reserveStock, allocate, match, post generation verbs (§0.14 GENERATE), shared across models named handlers once each
which verbs fire, in what order, under which condition the per-doc-type recipe data — the docAction fan-out descriptor a short list

What remains genuinely model-specific and intricate — BOM / phantom explode (MOrder), material transaction + costing + ASI (MInOut), the posting recipes — is a small set of named handlers, verified per cell against the M*.*It() oracle (§0.17), far smaller than the raw page count once boilerplate, guards, and the duplicated generation verbs are factored out. MInvoice.completeIt() is mostly calls to verbs O2C already proved — match, allocate, post — plus balance updates; its irreducible recipe is a handful of lines. prepareIt reduces the same way: its guards → rules, its reserveStock / explode → the same generation verbs as completeIt.

The genuinely trivial actions carry no handler at all — straight into the table (~3 lines each in iDempiere): approveIt (setIsApproved), rejectIt, unlockIt (setProcessing(false)), invalidateIt (setDocStatus(INVALID)).

And the reversal family collapses to ONE generic handler: voidIt / reverseCorrectIt / reverseAccrualIt / reActivateIt = "emit the inverse of the document's op-group." The op-log holds the original ops, so reversal is appending the negation — the per-model, notoriously buggy reverse code in iDempiere becomes a single op-log operation. This is the structural gift. (closeIt = set status + clamp the residual; near-generic.)

So the reduction is real, but the mechanism is factor the four concerns apart and de-duplicate the shared verbs — not "the code was empty." The bulk (status, guards, shared verbs, posting) is written once for the whole corpus; the per-model remainder is a short recipe plus a few verified handlers, completeIt / prepareIt included.

Why this is the migration solvent. Row migration is the easy part everyone already does. What makes ERP migration hell — and what locks customers into SAP/Odoo — is the behaviour: the lifecycle, the posting rules, the approvals. Nobody migrates behaviour because it is code in the source system. The moment behaviour is data (a transition table + named handlers verified against an oracle), migrating behaviour becomes migrating data. Every ERP's document lifecycle is the same shape — a state machine over documents descended from double-entry (1494). Onboarding a foreign instance becomes an adapter mapping their (status, action, transition) onto this generic table, and their schema onto the 5-table bridge; the engine never changes, only adapter rows. And because the target is an op-log, the migration is itself replayable, auditable, reversible — the same reversal-family handler that voids a document can unwind a bad import. You do not migrate into a new schema; you fold the old system's facts into the universal one.

The honest boundary. "Eat any instance" is earned one diff-oracle at a time. iDempiere is tractable because its DocAction is known and GardenWorld is the oracle. Odoo is open and its state machine is extractable (oracle buildable). SAP is the asymptote — closed ABAP plus decades of customer Z-code where the real behaviour hides; the honest claim there is "the standard flows + extractable config, with Z-customisations per engagement." The method is proven by the first clean abstraction — iDempiere's DocAction, the one being built now — and the rest is campaign, in sequence (iDempiere → Odoo → SAP standard → SAP custom), each gated by its own oracle. Migration removes the barrier to leaving; the grail (editable rules, live) supplies the reason to land. Both halves, or the solvent has nothing to pour into.

Odoo — second abstraction, WITNESSED (2026-06-03, §ODOO-FOLD PASS). Odoo 17 demo was stood up, one full sell-side O2C chain (SO S00023 → delivery → invoice → GL post → payment → reconcile) was driven to completion via RPC and frozen as a static oracle (build/erp/odoo_oracle.{json,db}, §0.12). A pure adapter (scripts/odoo_adapter.js — the Odoo↔iDempiere data dictionary, no business logic) folded that chain through the existing kernel verbs: newVerbs=[], all 5 hops mapped, effects reproduce Odoo to the cent, replay exact (scripts/poc_odoo_fold.js, log build/erp/odoo_fold.log). The solvent dissolved a second ERP with nothing invented — the strongest evidence yet that the verb set is general, not iDempiere-local. Honest bound (named, not hidden): this is ONE sell-side chain. Account determination (which GL account) came from Odoo as host data — the POST verb owns only ΣDR==ΣCR (§13.1), it does not re-derive Odoo's account logic. Full payment used FK-directed ALLOCATE.

Buy-side + partials, WITNESSED (2026-06-03). The campaign then folded three more Odoo chains, all newVerbs=[]: the full 3-way MATCH (PO P00011 → receipt → bill → post → reconcile, §ODOO-FOLD-3WAY PASS — the 6th verb now exercised, all six fold Odoo); the f7 partial-receipt (PO P00012 ordered 20 / received 12 / billed 12, §ODOO-FOLD-PARTIAL PASS — decomposes into an exact-match settlement leg + an FK-directed open remainder, §0.17); and f8 bill≠receipt (PO P00013 received 12, billed 8) — the one case that found a real engine gap. The SHIPPED exact-qty matcher pairs only when |qtyL−qtyR|≤tol, so it could not reconcile bill≠receipt. That gap is now closed (§ODOO-FOLD-F8 PASS): erp_engine.match gained opt-in partial-QUANTITY matching (opts.partial=true — pair min(qty), carry the remainder), reconciling to the unit (matched 8 + open-to-bill 4 == received 12) still via the SAME MATCH verb. The honest classification held through the fix: it was new matcher behaviour (code), NOT a new verb (newVerbs=[]) and NOT adapter-shaped (data) — and the exact-qty fast path stayed untouched, no regression.

Partial PAYMENT, WITNESSED (2026-06-03, §ODOO-FOLD-PAYPART PASS). The last Odoo bound the sell-side fold named was full-vs-partial reconciliation. A fresh chain was driven to a partial-payment state (SO S00027 → invoice 5002.50 → register a payment of 3000), leaving Odoo's computed residual 2002.50, payment_state='partial', frozen as a static oracle. The fold reproduces it with the same ALLOCATE verb at the smaller amount — residual = total − allocated, to the cent — newVerbs=[] and no engine change. Partial payment is the cleanest result of the campaign: it was free. (Contrast f8, which cost ~15 lines of matcher behaviour: the difference is that a partial payment is one allocation at a smaller amount, whereas a partial quantity match is a genuinely different pairing.)

Account determination, DERIVED (2026-06-03, §ODOO-FOLD-ACCTDERIV PASS). The one bound named at every step above was that the folds took Odoo's resolved GL accounts as host data — "reproduces given accounts." That bound is now closed. Odoo's determination CONFIG was extracted (product income = template account → category fallback; tax = the tax's repartition account; receivable = the partner property) and a resolver DERIVES the accounts from that config alone — reproducing Odoo's actual posting to the account (400000 Sales / 251000 Tax / 121000 AR), the derived double-entry balancing to the cent. It stays host glue, not engine (POST still owns only ΣDR==ΣCR), and the determination logic was learned clean-room from the config structure, never Odoo's source. The claim is raised from "reproduces given accounts" to "derives the accounts" — newVerbs=[].

Odoo, in sum: six folds with nothing invented — sell-side O2C, buy-side 3-way, partial receipt, bill≠receipt, partial payment, and account derivation — across all six verbs, newVerbs=[] throughout, with exactly one engine change in the whole campaign (the f8 partial-quantity matcher). The remaining items (multi-currency, anglo-saxon COGS) are optional claim-raisers, not blockers. Next abstraction: SAP — the asymptote. The allowed half is prepared (2026-06-03): a clean-room, blind schema/state-map HYPOTHESIS (scripts/sap_adapter.js) mapping the standard SD+FI chain (VBAK/VBAP → LIKP/LIPS → VBRK/VBRP → BKPF/BSEG|ACDOCA → BSEG clearing, with VBFA as the explicit derivation spine) onto the same six verbs, plus a runner (scripts/poc_sap_fold.js) gated to §SAP-ORACLE unavailable until a real export exists. The hypothesis is sharp precisely because S/4HANA's own redesign converged on our shape — one append-style journal (ACDOCA) + an explicit document-flow graph (VBFA). What is not done, and cannot be without violating non-invent, is the fold itself: that needs a real IDES/S/4HANA oracle, in its own session, never against an invented oracle. The honest claim stays "standard flows + extractable config; Z-customisations per engagement" — and the value of the SAP run is finding which Z-behaviour does not fold, not a foregone PASS.

The hard parts, worked through — why the showstoppers aren't

Three forward challenges look like showstoppers until you model them as the ledger already does. None requires the OLTP server, the lock manager, or the always-on coordinator a classic ERP carries.

1. Compacting the signed log — balance brought forward

The op-log is hash-chained for tamper-evidence — each entry carries the fingerprint of the one before it, so any later alteration is caught. After years it is millions of entries: too large for a tab, too slow to replay from the start. You cannot simply delete the old ones — the chain would snap and the old history could no longer be proven intact.

The resolution is the period close. At close, write one signed checkpoint carrying (a) the closing balances and (b) the fingerprint of the chain head, signed by the controller. That checkpoint becomes a new genesis: the next period chains off it; the closed period's entries are archived (cold), not deleted; the live tab carries only the open period + the last checkpoint. Tamper-evidence survives — re-add the archived entries and they must fold to the signed balance, to the cent, or fraud is proven. This is balance brought forward: total the books, carry the opening balance, box the journals in the archive. iDempiere already does the accounting half; we add only the signature and the fingerprint. The hash-chain checkpoint and the accountant's year-end close are the same ritual — the domain solved this 500 years ago. (Optional reinforcements: a Merkle root keeps per-transaction provability of a closed period in 32 bytes; emailing the checkpoint fingerprint to yourself binds even the signer.)

Back up the recipe, not the result — 1 TB → ≈500 MB

The persisted artifact is the signed op-log, never the materialised database: balances, postings (Fact_Acct) and every derived table are a deterministic fold of the log, recomputed on load — never stored. Period-close compaction keeps only the open period live (scripts/poc_volume.js §VOL PASS — bootstrap stays flat as history grows 100×). So a transaction-heavy 1 TB ERP backs up as ≈500 MB: you carry the journals, not the ledger they fold to.

Two honest caveats on "replay reconstructs everything." (1) What replay reproduces exactly is the net result: the disjoint per-branch folds commute, so the union of signed logs re-folds to identical balances in any order (witnessed maxDiff=0cscripts/poc_blackout_resume.js §ORDER-HONEST). The one thing not reconstructible from the logs alone is the cross-branch CAS arbitration order for a contended op-class — a bounded sliver routed to the ledger, minimised live to a measured quorum-RTT window (scripts/poc_quorum_cas.js). (2) The ≈500 MB is the full-replica figure (a facilitator or the owner's own channel); an edge device carries far less — its own slice + the shared state it touches (≈13 MB resident, not the whole chain — see MigrateComparisonPaper DR/TCO). "Every device = 500 MB" is No for edge roles.

2. Atomicity — the document is the atomic unit

Atomicity — all of a document's effects, or none — needs no transaction manager here, because there is no multi-row UPDATE to half-complete. A document-event is one op-group (§18.8), appended to the log as a unit; state is the fold over it. The group folds in completely or not at all, and a torn write fails its own hash. Atomicity is structural — the same dissolve-don't-coordinate move that removed contention: delete the multi-write transaction, keep the single atomic append.

3. OLTP — physics is the lock, the ledger is the witness

Classic OLTP spends its effort on isolation: row locks / MVCC so concurrent writers do not clobber shared state. The DistributedERP doctrine (DistributedERP.md §1–§6) shows that shared state is mostly a modelling artifact:

  • Atomicity — solved above (the op-group).
  • Consistency — the deterministic kernel + guards (period control included) enforce invariants on apply and on replay.
  • Isolationphysics partitions the writers. A till owns its sales, a van owns its load, a box-in-hand owns itself — two writers cannot touch the same aggregate because the atoms have a location (§2). The one genuinely shared thing — the last unit, a global entitlement — is the single CAS op-class (§5): one set-if-unset, not a lock manager.
  • Durability — asynchronous: the local append is instant; durability lands when the log is relayed (W-PERSIST). The one honest trade (§19.6) — synchronous durability for async convergence.

Per terminal this wins: in-RAM apply of the op-group, no network per transaction (~1–3 orders faster than networked JDBC, §19). What remains is mechanical, not theoretical — maintain the read-projection incrementally at high append rates, bounded by the period checkpoint so the working set stays small.

And the deepest case — stock — is where the ledger earns its keep. You do not lock the world to prevent an oversell; in a partition you cannot, and every system that claims to is secretly record-and-consequence (DistributedERP.md §0, truth 4). Instead: the physical count is the truth; the ledger is the running expectation; reconciliation surfaces the difference. The scan is the op — you cannot scan a unit that is not physically there — so a physical unit cannot be double-committed. The ledger's job is not to prevent the discrepancy but to tell you the stock is off so you reconcile: POS → BOM backflush → replenishment → physical reconciliation. Physics is the truth; the ledger is the witness that the books drifted.

What it leaves you — keep just the ledger

Strip the machinery a classic ERP needs for these — the transaction manager, the lock/MVCC layer, the always-on OLTP server, the sync coordinator — and what remains is the ledger: a signed, append-only op-log; its fold (the balances); and reconciliation against the physical world. Compaction is balance b/f; atomicity is one op-group; concurrency is physics + one CAS; multi-site is a dumb async facilitator that only orders and relays (DistributedERP.md §6), never an authority. The hard parts are not unsolved — they are re-expressed as accounting, the one part of an ERP that was always going to stay. It has been thought through; the ledger is enough.

Witnesses — these are runs, not arguments (dated, headless, replay-deterministic). The three hard parts above are exercised on the actual editing-layer op shape in scripts/poc_showstopper.js (§SHOW PASS): the document-event op-group folds all-or-none (a torn op is rejected whole), the period-close checkpoint re-folds the cold archive to the signed balances to the cent with the tamper caught at the exact op, and the single CAS holds across the checkpoint boundary. The "mechanical, bounded by the checkpoint" claim is then measured in scripts/poc_volume.js (§VOL PASS): bootstrap from the last checkpoint stays flat as total history grows 100× while a full replay grows with it — the working set is bounded by the period, not the log; the binding constraint is the per-op hash (append and verify), which sets the close cadence, not a wall. And the durability path — the inbox as the recoverable signed log — is stress-tested in scripts/poc_email_dr.js (§EMAIL-DR PASS): the data recovers unconditionally from any reachable valid snapshot, but the key does not live in the inbox — without an anchor the encrypted snapshots are undecryptable, and the three anchors that close that gap (own k-of-n channels, corporate escrow, platform passkey) each add a named, non-zero trust. The regress terminates for the fact unconditionally; for the key, only at a chosen anchor — and naming it is the floor.

Performance, measured (not asserted). scripts/poc_volume_ceiling.js pushes the log/fold layer to 20M ops with no wall and a linear curve (~437 bytes/op); scripts/poc_volume_sqljs.js re-measures on the actual browser stack (sql.js + crypto.subtle), where a per-op append is ≈15 µs, bound by the async hash, not by SQLite, and the fold (a SQL GROUP BY over a bounded chart of accounts) stays sub-frame. Against a legacy central database the gap is set by where the work happens: scripts/poc_legacy_ab.js shows that on one box Postgres ≈ local SQLite (both ~1 ms, fsync-bound) — so the ~100× there is honestly the async-durability trade (§19.6), not server-removal. scripts/poc_remote_pos.js shows the remote case, where it matters: a POS sale's cashier-perceived latency is RTT-bound for a central DB (tens to hundreds of ms, and the till cannot sell when the link is down) versus a local apply plus an asynchronous relay to HQ — RTT-independent and offline-capable; a 10k-document batch-plus-charts runs locally with no network and no per-chart round-trip (~12× over a fair server-side batch at cross-region latency). Every legacy figure is raw SQL, excluding the ORM/OSGi/JDBC layers that only make it slower — a floor, not a ceiling. The one honest cost throughout is the same async-durability trade; the gains are server-removal and locality, named, not rounded up.

Where this sits among the fast-SQLite systems — and the claim I will not make. Embedded SQLite at scale is not new: Expensify's Bedrock, Cloudflare's D1, Turso/libSQL all run it in production. On a single box, with durability matched, an in-process engine is roughly 3× a networked one on writes — and that advantage inverts under heavy concurrency, a benchmark a fair critic will (and should) cite. So I do not claim a faster engine. The real difference is the write path: every one of those peers keeps strong consistency by escalating each write to a central primary over the network. This design does not — a write applies locally and the signed op-group is relayed asynchronously, with a single compare-and-set for the one genuinely shared resource. That is a different consistency model, not a faster engine; its cost is eventual convergence, and its dividends are the two things a round-trip-to-a-primary architecture cannot give a till: it keeps selling when the network is down, and there is no write-contention to lose under concurrency, because each terminal is the single writer of its own partition. The speed argument is modest and conditional; the architecture argument — offline, single-writer, signed — is the one that holds.

A closing note, to the version of me from two years ago

The two years spent proving the imperative extraction does not converge were not wasted — they are the evidence that the declarative-extract path is the one that does. The grail was never going to be reached by carrying the old engine somewhere lighter. It is reached by realizing the engine should have been data all along, and that the one thing a code-engine structurally cannot give you — a rule you edit while it runs, safely — falls out for free the moment it is.


Grounding: ERP.md §0.4 · §0.5 · §0.9 · §0.10 · §0.17 · §0.18 · §2d-3 · GLASSBOWL_DOSSIER.md · the §20 prototype addendum. Every claim of extraction here is witnessed in a dated log; nothing on this page is asserted that the source data does not support.