Front matter
Introduction · How to read this book
Most manuals tell you what the functions do. This one tells you why the engine is shaped the way it is — because in a fold engine the shape is the story. Roughly 28,000 lines here do work a 1.4-million-line server used to do, and the difference isn't cleverness; it's a single idea carried all the way down: state is never written, only folded; claims are never asserted, only witnessed. Every chapter is a consequence of that one sentence. Learn it once and the rest stops being surprising.
What this is — and isn't. This is the deep manual for the people who extend the engine: write a rule, a callout, a posting, a plugin; edit the dictionary; prove a change is correct. It sits beneath the ERP Rosetta Stone — that's the idiom map (your Java method → our fold); this is the layer under it: how to think in the engine. It is not an API reference (the engine is too small to need one) and not a feature tour. Where it can, it cites; it doesn't repeat.
Two readers, one book. Every chapter is tagged base (start
from zero — solid JS and general ERP literacy assumed, no iDempiere needed) or veteran
(you carry twenty years of AD / ModelValidator / 2Pack instinct — take the fast-path). And in
every chapter there is a From iDempiere → box: a newcomer reads the full prose; a veteran can skim
just those boxes across the whole book and get the entire map at speed. Same page, two depths.
The shape of a chapter. They all run the same six beats, on purpose: the hard problem → the wrong instinct → the fold reasoning → the code → the witness that proves it → the scar. The "wrong instinct" is usually the Java/OSGi reflex, named so you can catch yourself reaching for it. The "scar" is a real episode that cost us once — the slice that hid windows, the cache that re-bought the server — written down so it costs you nothing.
The one discipline. This project lives by non-invent: every claim traces to a
witness, a file, or a §-log. So does this book — no chapter ships without naming the W-… that proves
it. If you ever read a paragraph here you can't trace to running code, that's a bug in the book.
Scope. Only what you touch (rules, callouts, posting, plugins, dictionary edits) and what you must read to touch it safely (the op-log, the verb set, the money library). The kernel internals you never open — the WASM/sql.js plumbing, the signing crypto, the sharding — are deliberately out; where you depend on them, one chapter (Ch.11) tells you what to trust, not how it's built.
/) filters the tree to matching topics and highlights every
hit in the page; Esc clears it. Read Chapter 1 forward, or search for the thing you're stuck on.About this book — what's new here (and what isn't). Op-log programming itself is not new. The append-only log as the source of truth is well-trodden under other names — event sourcing, operation-based CRDTs (the PO-Log), git-for-data — with mature frameworks (Eventuate, Apache Pekko), pattern catalogs (Microsoft's Event Sourcing pattern), and even a peer-reviewed ERP case study. We stand on that ground, not apart from it. What we have not found is a field manual — a war-story problem → wrong instinct → fold → witness book — that carries op-log programming all the way to a working ERP, oracle-diffed to the cent. That combination, and this manual form, is what's distinctive here. If a book like this already exists, tell us and we'll cite it — that invitation is itself the §FALSIFIER this book lives by.
Closest prior work. The nearest published effort uses event sourcing for ERP traceability — Vasconcellos, Bezerra & Bianchini, “Applying Event Sourcing in an ERP System: A Case Study” (CLEI 2018). We differ on one load-bearing point: there the business rules live in source code outside the log, so replaying an old event needs the matching code version; here the rule-set is the declarative dictionary, folded and itself signed into the same log — so the rule version travels with the data.
Read more — what that paper found, and how this engine extends it
What it is. A single industrial case study of Liera, an ERP a Brazilian logistics company built by replacing an old ERP module-by-module during live operation, under famously complex tax law. It treats event sourcing as a software-architecture practice (paired with Domain-Driven Design and CQRS), not as accounting doctrine — the accounting is just the domain that hurts most when you can't replay history. It names the disease precisely: the “internal state history loss problem” — software overwrites state, so the past becomes unanswerable.
What it confirms (independently of us). The headline win was “dramatically increased error traceability”: debug-by-replay — mirror the production DB, find the event, erase forward, re-run the user's action. Plus answering past “why” questions (why this tax rate, why this order was denied three times), and an unexpected benefit — non-technical stakeholders could read the event flow. Those are our Ch.1 (history is the substrate) and our “the op-log is readable”, reached by another team in 2018.
Their hardest problem — which we largely solve. Event-version vs code-version drift: to replay an old event you need the rules as they were then, but the rules sit in source code, outside the log. Our engine keeps the rules in the declarative AD, folded, and as signed ops in the same log (Ch.9) — so the rule version is carried by the data, not by a separate code checkout.
Their drawbacks = our already-built answers. They needed snapshots for replay performance → our
signed checkpoints (0.90 ms vs 47.70 ms). Their optimistic concurrency via a SourceId–Version
pair → our verifyChain / quorum-CAS (Ch.11). Their DB dilemma (MSSQL native transactions vs
MongoDB schema-flexibility; they picked Mongo and worried) → we skip it entirely (SQLite-WASM, no server). They
close honestly that ES is still “a niche solution while it matures” — the same humility as our Ch.12.
The gap we fill. They use event sourcing for traceability inside a conventional architecture — DDD aggregates, a server, a database of record. We make the op-log the only source of truth, fold a declarative dictionary into the running app, and diff the accounting to a real iDempiere oracle to the cent. Predecessor and validation, not duplicate — which is exactly why we cite it.
Part I · What you must read
Ch.1 · Why a log, not a database base
The problem. You want a customer's balance. The reflex is the same whether it's twenty
years deep or two weeks of a tutorial: open a connection, SELECT SUM(...) from the table that holds
The Truth. The table is the system of record; everything else is a visitor that asks it politely.
The wrong instinct. Carry that here and you'll hunt for the table that holds the balance and not find it — and assume it's missing. It isn't missing; it was never the design. The moment a mutable table is the source of truth, you've bought a server to guard it (who may write?), a backup story (what was true last Tuesday?), and a sync problem (which of my five sites is right?). Those three are most of what an ERP team maintains. We didn't optimize them — we deleted the thing that causes them.
The fold reasoning. The source of truth is an append-only log of signed operations
(kernel_ops). Nobody edits the balance; someone appends "order #23 completed, +5002.50",
signed. The balance is folded — replayed from the log, deterministically, the same on every node. It has a
name you know: git-for-data. A git repo has no "current state" table; it has commits, and your working tree
is a fold of them. The op-log is the commits; the grid you see is the working tree. Three costs vanish for free:
who can write → the signature (a bad op fails verifyChain everywhere — no server to ask);
last Tuesday → replay to Tuesday (history is the substrate, which is why time-travel was cheap);
which site → all of them, eventually (no master to be right first).
Fact_Acct row you SELECT and trust
as final, and no AD_ChangeLog table you read for history — both are projections folded from the
op-log here. The one instinct to unlearn is the hunt for the table-of-record; once you stop looking for it,
everything else (the AD, the documents, the postings) you'll recognise on sight. The op-log is your
AD_ChangeLog promoted from audit trail to source of truth.The code. A fold is not exotic — it is reduce with a verb table:
// the shape — not the literal source, but the truth of it
function fold(ops) {
return ops.reduce((state, op) => verbs[op.verb](state, op), emptyState())
}
verbs is the closed set — CREATE_DOCUMENT, SET_STATUS,
POST, ALLOCATE, and the rest. Every business event is one of them. There is no
verbs.runArbitraryJava. That absence is the doctrine, not an oversight: foreign imperative code never
enters the fold — it lives at the edge, as a plugin (Ch.8).
The witness. W-AD-OPLOG-DISTRIB: edit an AD_Window on node A,
sign the append, ship only the log line to node B, fold the same dictionary there — verifyChain
passes. B never saw A's database, only its log, and reached identical state. Bootstrap from a signed checkpoint is
~53× faster than genesis replay (0.90 ms vs 47.70 ms) — a checkpoint is just a fold someone already did and signed.
The scar. Early on a cached projection got written to directly instead of appending an op. It worked — until one node's cache disagreed with its own log, and nothing could say which was right, because we'd recreated the exact mutable-truth problem we'd deleted. The fix wasn't a better cache; it was deleting the write path. First rule of the engine: state is never written, only folded. If you're assigning to the projection, stop — you're about to buy back the server.
Part I · What you must read
Ch.2 · The fold & the closed verb set base
The problem. You're handed a 26 MB dictionary — tables that describe tables, windows, fields, rules — and told it's an ERP. Where's the application? You can read the dictionary all day and not find the part that runs.
The wrong instinct. You assume the dictionary is configuration — a settings file read at startup to decide which fields to show. That's correct in iDempiere: the AD drives a generic ZK UI engine that interprets it at runtime. So you hunt for the engine. Here there isn't one to find — and that's the point.
The fold reasoning. The dictionary isn't read as config — it is folded into
the app. The AD is the program. ad_parser folds AD_Window / AD_Tab /
AD_Field straight into a live window, using the same fold that replays the op-log, just pointed
at the dictionary instead of the transaction stream. No ZK tier sits between dictionary and screen — the window
is a fold of AD rows, and when a row changes (a signed op), it re-folds (Ch.9). And every event the
engine can perform is one of a closed, finite verb set — CREATE_DOCUMENT,
CREATE_LINE, SET_STATUS, POST, ALLOCATE, a handful more. No
open-ended "run this code" verb. That closure is the load-bearing wall: it's why folding is deterministic,
and why migration works — any ERP's flow maps onto these verbs, or it's a plugin (Ch.8).
MWindow to subclass. To change a field's behavior
you change the dictionary, not a renderer — because the renderer is the dictionary, folded. And
o.processIt(DocAction.ACTION_Complete) — the Java entry that routes the document lifecycle — maps to
a fold verb: commitGroup({ DOC_ACTION:'CO', … }). processIt is the call; the verb is what
travels in the log.The code. One mechanism, two streams:
const window = fold(adRowsFor(windowId)) // AD_Window/Tab/Field → live UI
const doc = fold(opsFor(documentId)) // CREATE_DOCUMENT/SET_STATUS/POST/… → data
The dictionary stream gives you the app, the op stream gives you the data. Learn fold once and
you've learned the engine's spine.
The witness. The verb set is proven sufficient by migration, not asserted. Folding a
real Odoo sales order — order→ship→invoice→pay→allocate, every hop — reported newVerbs=[]: the
flow mapped entirely onto existing verbs, nothing left over, GL balanced (ΣDr==ΣCr) to the cent. A foreign ERP
landing on our verb set with no remainder is the evidence the set is closed and complete enough to be the
target.
The scar. We once shipped a 12.7 MB seed by column-slicing the dictionary to brag a bigger ratio. It folded… mostly — then whole windows came up with missing fields, some unreachable, because the fold needs columns the slice threw away, and a fold of an incomplete dictionary fails silently: a plausible window that's quietly wrong. Fix was the full-width 26.1 MB seed (ratio fell 3.5×→1.7×; we took it). Rule: never slice the AD to save bytes — completeness beats ratio. (That's why §10's seed row reads 1.7×, not the prettier number.)
Part I · What you must read
Ch.3 · DERIVE / VALIDATE / ACT base
The problem. You have a change to make and you want a place to hang the logic. Every framework trains you to look for the hook — the method to override, the event to subscribe to. So your first question here is "where does my code go?" That is the wrong first question.
The wrong instinct. You reach for a lifecycle hook — a beforeSave, something
to subclass — and pour arbitrary code into it: compute a value, check a condition, change a status, all in one
method body. It works in a monolith because the method body can do anything. Here it can't, and you'll fight the
engine until you see why.
The fold reasoning. Before you write a line, classify the change into exactly one of three effects — because three is all the op-log can carry. DERIVE: compute a value and put it in a field (a default, a callout). No decision, just fill. VALIDATE: judge the proposed state and accept or reject it with a message. A pure predicate — it performs nothing. ACT: perform a verb — change status, post, allocate. The only effect that appends a state-changing op. The automation law is exactly this: every automation is DERIVE (fill), VALIDATE (gate), or ACT (do), classified by its op-log effect. Get the classification right and the engine slot is obvious; get it wrong and nothing fits.
ModelValidator timing points collapse onto these
three. A callout / onchange is a DERIVE. A modelChange/docValidate
that throws is a VALIDATE. A DocAction / o.processIt() / completeIt
is an ACT — processIt is Java's routing call into the document lifecycle; completeIt
is the model method it dispatches to; here both collapse into one commitGroup verb. The habit to
unlearn is doing all three inside one beforeSave — here they are separate effects with separate homes
(Ch.5 / Ch.6 / Ch.7).The code. A handler declares its effect; the engine routes it:
{ effect:'DERIVE', on:'C_Order.C_BPartner_ID', run:r => ({ C_PaymentTerm_ID: bp.paymentTerm }) }
{ effect:'VALIDATE', on:'C_Order', run:r => over ? reject('Credit ceiling exceeded') : ok() }
{ effect:'ACT', verb:'SET_STATUS', to:'CO' } // the only one that appends an op
The witness. The engine modules split on exactly this seam — ad_callout.js
(DERIVE), ad_modelval.js / ad_valrule.js (VALIDATE), ad_docfsm.js (ACT) —
each headless-proven in the coverage matrix. The three-way split isn't a teaching device bolted on after; it's
how the code is actually organised.
The scar. A "validator" once mutated a field while judging — a hidden ACT inside a VALIDATE. On replay it fired again and double-applied, because VALIDATE is supposed to be pure and replayable and ACT is the only effect allowed to change state. Rule: a validator decides, it never does. If your VALIDATE writes anything, it's a mis-classified ACT — move it.
Part I · What you must read
Ch.4 · Money to the cent base
The problem. An invoice line: 3 × 19.99, 6% tax, converted across two currencies. In JS,
0.1 + 0.2 !== 0.3. Money on floating point is wrong, and in accounting "wrong by a cent" is not
close — it's the books not balancing.
The wrong instinct. Use Number and round at the end. By "the end" the drift
is already baked in: every intermediate multiply has shaved or added fractions of a cent, and the final
toFixed(2) just hides where.
The fold reasoning. Money never touches float. Amounts accumulate as integer cents.
The only non-integer steps — FX conversion, proportional tax — multiply in BigInt off the
exact-decimal rate, rounded HALF_UP, the same rule Java uses. bigdecimal.js is
~140 lines and you use it; you never roll your own arithmetic. It is not a reason to keep the server — it's
a small library, and the discipline is what matters, not the language.
BigDecimal + setScale(2, HALF_UP)
discipline ports 1:1 — same accumulation, same rounding, different language. This is the cleanest "the discipline
ports, the language doesn't matter" in the whole book: copy your mental model verbatim.The code. Cents are integers; the exact-rate multiply is the only careful step:
let net = 0n; // integer cents, BigInt
for (const l of lines) net += BigInt(l.qty) * BigInt(l.priceCents);
const fx = mulRate(net, '1.0834'); // bigdecimal.js: BigInt off exact decimal, HALF_UP
The witness. poc_money_fold — the fold's money math is unit-proven
bit-equal to Java BigDecimal; the POS lens diffs to §POS-CENT maxDiff=0c. Not
"close enough" — identical, byte for byte, to what iDempiere computes.
The scar. An early FX path multiplied with Number and drifted a single cent on
a cross-currency invoice. One cent meant ΣDr ≠ ΣCr and the journal would not balance — the document looked fine
and the books were broken. The fix wasn't fudge-rounding; it was BigInt off the exact rate.
A cent is never a rounding nuisance — it's the ledger telling you the arithmetic is wrong.
Part II · What you touch
Ch.5 · The validator veteran
The problem. Enforce a rule: no Sales Order may complete over the customer's credit ceiling. Real customisation — every shop has a dozen of these.
The wrong instinct. Write a ModelValidator, override docValidate,
throw an exception, package it as an OSGi bundle, build, deploy, restart. You've done it a hundred times.
The fold reasoning. This is a VALIDATE effect (Ch.3). If the rule is expressible as a
logic expression over the row, it's an AD_Val_Rule — pure data, folded, no code at all. If it
needs an imperative comparison, it's a registered handler in ad_modelval.js that returns
reject(message). It only "stays a plugin" (Ch.8) when the logic genuinely reaches beyond the
dictionary. Most validators you'd have written are an AD_Val_Rule row you never compile.
ModelValidator → AD_Val_Rule /
ad_modelval. This is the change we counted in §7 of the cost paper: the same rule that needs a Java
bundle there is 9 handling checks → 4 here, with build+restart 2 → 0 — because there is no bundle to
build and no server to bounce.The code. Declarative where it can be; a pure predicate when it can't:
// declarative — a row, not a class:
AD_Val_Rule: "(@OpenBalance@ + @GrandTotal@) <= @CreditCeiling@"
// imperative — still a predicate, performs nothing:
{ effect:'VALIDATE', on:'C_Order', run:r =>
r.openBalance + r.grandTotal > r.creditCeiling ? reject('Credit ceiling exceeded') : ok() }
The witness. ad_modelval.js is headless-proven in the coverage matrix; the §7
effort count is the witness for the cost side — the rule lands, and it lands cheaper, both measured.
The scar. The Ch.3 sin in its natural home: a credit check that also stamped a "on hold" flag while judging. That stamp is an ACT smuggled into a VALIDATE; on replay it re-stamped. A validator returns a verdict, never writes. If you need the hold, that's a separate ACT (Ch.7), fired after the gate passes — not inside it.
Part II · What you touch
Ch.6 · The callout veteran
The problem. When the user picks a Business Partner on an order, default the payment term and price list from that partner. The classic on-change derive.
The wrong instinct. A Java Callout wired through the ZK form, shipped in a
bundle, firing on the field's onchange.
The fold reasoning. This is a DERIVE effect (Ch.3). AD_Column.Callout
holds the binding — declarative data: which column triggers which derive. The body is a registered
handler dispatched by ad_callout.js. The binding is data you can edit; the handler is pinned host
glue so the engine's default-handler set doesn't silently grow (that pin is load-bearing — see the scar).
Callout → AD_Column.Callout →
ad_callout. And the binding is genuinely data: W-NINJA-CALLOUT writes a
Col@class.method binding into AD_Column.Callout from the Ninja Excel face — you can
author a callout binding without touching code at all.The code. Register the body as host glue; keep the default set pinned:
// binding (data): AD_Column.Callout = 'CalloutOrder.bPartner'
register('CalloutOrder.bPartner', r => ({ // DERIVE
C_PaymentTerm_ID: bp(r).paymentTerm,
M_PriceList_ID: bp(r).priceList,
})); // installDefaultHandlers stays pinned at 6 — this is host glue, not a new default
The witness. W-CALLOUT (the default-handler count holds at 6) +
W-NINJA-CALLOUT (binding written into AD) + the live CREATE leg (#331) where bill + price list
default from the BP on a real iDempiere create form.
The scar. Registering a callout the wrong way bumped the default-handler count and
tripped the W-CALLOUT pin. The engine's handler set is reproducibility's anchor — if it drifts, two
nodes fold differently. Rule: new derives register as host glue; the core default set never grows. A moving
default set means the fold isn't reproducible.
Part II · What you touch
Ch.7 · Posting rules veteran
The problem. Completing a Sales Order must post to the General Ledger — debit and credit lines that balance to the cent, against the right accounts.
The wrong instinct. Write a Doc_Order Java class extending the posting engine,
override createFacts, recompile the server.
The fold reasoning. Posting is a fold, not a class. post_resolver.js
derives the Fact_Acct lines from the document plus the accounting schema; completeIt is
a fold from order to journal. The famous MOrder.completeIt() archetype is ~250 Java LOC; the fold of
its posting is ~50 — not because JS is terser, but because the boilerplate (PO/Doc plumbing, session, connection)
is gone, leaving only the verbs.
Doc_* / Fact_Acct →
post_resolver; completeIt → the fold. ERP_MODEL_ARCHETYPE.md names
MOrder + ~25 completeIt deltas as the real denominator — the work is enumerated, not waved at.The code. Derive the lines, append one POST op:
function post(doc, schema){
const facts = post_resolver(doc, schema); // → balanced Fact_Acct lines
assertBalanced(facts); // ΣDr === ΣCr, integer cents (Ch.4)
return append({ verb:'POST', facts }); // ACT (Ch.3)
}
The witness. The completeIt fold diffs to the real iDempiere oracle
(idempiere_test, 300 fact_acct rows) at maxDiff=0c — proven, not asserted.
#338 renders that GL to the cent on iDempiere's own invoice window.
The scar. On a freshly-migrated tenant, "Accounts Posted" is honestly absent — there are no postings yet — and we render that absence truthfully rather than fabricate a posted state to look complete. An empty journal shown honestly beats an invented one. Non-invent is a UI rule too, not just an engine rule.
Part II · What you touch
Ch.8 · Writing a plugin (.foldbundle) base
The problem. A genuinely custom rule — a bespoke rebate scheme no dictionary expresses, real imperative logic that is yours and only yours.
The wrong instinct. Add a verb to the engine, or fork the fold to special-case it. Now the verb set isn't closed (Ch.2), and every node needs your engine fork to replay your data.
The fold reasoning. The doctrine line, and it is absolute: foreign imperative code is
never auto-imported into the fold — it lives at the edge, as a plugin (.foldbundle), a registered
handler the engine calls but does not absorb. The closed verb set stays closed; your custom logic
emits existing verbs and effects, it doesn't add primitives. The engine's
installDefaultHandlers set stays pinned (Ch.6); your plugin registers alongside it.
.foldbundle
module. Gone: MANIFEST.MF, Import-Package ranges, Tycho, the p2 target platform, the
bundle-resolve gate, the server restart. You ship a JS module, it registers, you re-fold. Your business logic is
the same; everything around it evaporates.The code. A module that exports an effect-tagged handler and a binding:
// rebate.foldbundle — registered at the edge, engine untouched
export default {
bind: 'C_OrderLine',
effect:'DERIVE',
run: line => ({ RebateAmt: customRebate(line) }), // emits a value; never mutates state
};
The witness. W-PLUGIN — the plugin system loads a .foldbundle and
runs its handler, with the engine's default set provably unchanged.
The scar. A plugin once reached into engine internals and wrote the projection directly — the Ch.1 sin, committed at the edge this time. Plugins EMIT — ops and effects — they never mutate state. A plugin that writes the projection is a plugin that breaks replay on every other node. The edge is a place to compute, not a back door to the kernel.
The kernel boundary — stated plainly for contributors. The 5-relation substrate
(Entity / Attribute / Value / Relationship / Event) and the closed verb set (the ~77 COBOL-era
primitives in Ch.2) are immutable by design. A pull request that modifies the kernel is rejected by
definition — not by policy, but because adding a primitive breaks deterministic replay on every node that
has folded without it. The only legitimate surface for new ERP verbs is the .foldbundle plugin
seam: emit existing primitives, register a new handler, done. If you find yourself needing a 78th verb, the
answer is that you haven't decomposed the intent yet — every real business rule has folded cleanly into the
existing 77 so far.
Part II · What you touch
Ch.9 · Editing the dictionary itself veteran
The problem. Make a field read-only, hide a column, add a field — a change to the dictionary, in production, across five sites.
The wrong instinct. Edit the AD, export a 2Pack, apply it to each instance, synchronise the physical column, restart each server. The migration ritual, performed N times, verified N times.
The fold reasoning. A dictionary change is a signed op like any other (Ch.1). Edit the
AD_Field, sign the append, and the open form re-folds on the spot — field set 26→25→26 —
because the window is the dictionary folded (Ch.2). No reload, no recompile. And it propagates by op-log:
the signed edit replays to every node, no 2Pack per instance. This is the §7 D-axis — a per-instance
migration ×N collapses to one signed append.
The code. A signed CRUD op on the dictionary; the refold hook repaints the open window:
append({ verb:'UPDATE', table:'AD_Field', id, set:{ IsReadOnly:'Y' }, sig });
// refold hook: invalidate the open window, re-fold from the new AD — no reload
The witness. W-AD-SELFEDIT (live: the M_MatchInv column vanishes
Y→N, the form repaints, no reload) + W-AD-OPLOG-DISTRIB (the edit folds identically on node B from
the log alone).
The scar. Two honest edges, named not hidden: editing a CO/CL (completed/closed) order
silently no-ops where the FSM should block and warn — a known gap. And the deeper audit: editability must
fold from AD (IsUpdateable/IsReadOnly/IsView + role), not a
hand-curated allow-list. "General, not custom" — the dictionary governs itself; a hardcoded list is a smell
that the fold isn't reading deep enough.
Part III · Proving it & the rules
Ch.10 · The oracle & how to write a witness base
The problem. You folded completeIt. How do you know it's right — not
just green?
The wrong instinct. Write a unit test that asserts the output you expected. It passes — and proves only that you agree with yourself. A tautology in a green checkmark's clothing.
The fold reasoning. Correctness here means matching a real iDempiere, not a
hand-written expectation. The oracle is a live iDempiere (idempiere_test, fact_acct=300)
you diff against to the cent — maxDiff=0c. And every witness carries a §FALSIFIER: a stated
condition that, if it held, would prove the claim false. A test that cannot be falsified isn't a test. You
write a witness by naming the issue it proves or disproves, running the fold, diffing to the oracle, and
reading the §-log — exit code is not evidence.
The code. Run it, diff it, read the log:
bash build/erp/run_witness.sh scripts/poc_completeit.js # → §-log + diff_oracle.log
# §FALSIFIER: if any Fact_Acct line differs by ≥1c from idempiere_test, FAIL loud.
The witness. The coverage matrix — 43 surfaces oracle-diffed, each carrying a load-bearing §FALSIFIER, so a passing diff can't be a tautology. The book you're reading is held to the same bar: every chapter names one.
The scar. We shipped a green test that proved nothing — it asserted our own output. That's where the §FALSIFIER habit was born. Every test must name what would make it wrong, or it's theatre. A test you can't fail is a test that tests nothing.
Part III · Proving it & the rules
Ch.11 · The distribution you rely on base
The problem. Five devices, no server, all editing — and they must converge without a master. (Scoped: this is what to trust, not how the kernel is built — you rely on this layer, you don't author it.)
The wrong instinct. Stand up a sync server, or take last-write-wins. The first re-buys the server you deleted in Ch.1; the second silently loses data.
The fold reasoning. Every op is signed; verifyChain validates the whole
chain on each node; convergence is replay of the merged log; contention resolves by quorum/CAS. Three things you
must hold as true: an op without a valid signature is rejected everywhere (authority is in the data, not a
gatekeeper); history is the chain (nothing is overwritten, only appended); there is no first-to-be-right.
The code. Sign, append, relay; verify on receipt:
relay(sign(op)); // local append + async fan-out
onReceive(op => verifyChain(log.concat(op)) ? apply(op) : drop(op));
The witness. W-QUORUM-CAS (a contended commit resolves in ~18 ms) plus the
DR/TCO edge suite — the distribution behaves under contention, measured, not hoped.
The scar. An early relay did last-write-wins and silently dropped a concurrent op. The fix was signed append + chain — nothing overwrites, everything appends — the same Ch.1 rule, now stretched across the wire. The rule that saves you locally is the rule that saves you distributed.
Part III · Proving it & the rules
Ch.12 · The scars & the discipline base
The problem. How does a 28K-LOC engine make honest claims against a 1.4M-LOC, twenty-year incumbent without lying — to its users or to itself?
The reasoning — the rules of the house. Non-invent: extract from the dictionary and the live oracle, never guess a value. Witness-or-it-didn't-happen: a claim with no §-log is not done. Every claim wears a §FALSIFIER. And the honesty that costs you the pretty number: the LOC ratio fell 89× → 76× → 51× as real coverage grew, and we headline the conservative ~21× at parity — not the early eye-catcher — because the ratio should fall, and saying so out loud is the proof we're not cherry-picking. Only ~0.2% of the M-class logic is folded; delivery ≠ feature parity, stated plainly, every time.
The scars, gathered. Each cost us once; written here so it costs you nothing.
- The cache write-path that re-bought the server (Ch.1) → state is never written, only folded.
- The slice-holes that hid windows (Ch.2) → never slice the AD; completeness beats ratio.
- The validator that mutated (Ch.3/5) → a validator decides, never does.
- The callout that moved the pin (Ch.6) → the default handler set never grows.
- Accts-Posted honestly absent (Ch.7) → show the empty journal, never the invented one.
sw.jsthe eternal conflict magnet → take the higher version, keep both precache hunks.- The docs-deploy landmine that wiped live pages twice → publish only through the no-shrink seatbelt.
- The Process gate —
DOC_FAMILYis a ported whitelist, not a pure AD read → detect-from-AD is general; execute is gated to ported families; an un-ported table renders Process honestly absent. - The fold that ran on every tick — a contributor wired a fold into the render loop, not a user-action handler; GC pressure caused micro-stutters in the Three.js loop within minutes. Rule: folds fire on action, never per-frame. The render loop holds geometry; the fold engine holds state. They share a thread but must never share a tick.
- The "you'll need checkpointing" concern — not pending. The baked
ad_seed.dbis the snapshot: a signed, verified freeze of the log at a known state. Page load folds from the seed, not from op-zero. Compaction is an operator action in the System Monitor (SystemMonitor.resetSeedClients), not an architectural open question. A skeptic who raises this has solved a problem the engine already solved differently.
Housekeeping · answers for skeptics
Two concerns raised by external reviewers, answered plainly so they need not be raised again.
GC pressure and render-loop stutters. The ERP fold engine and the Three.js render loop are decoupled — they share a thread, not a tick. Folds fire in response to user actions (open window, commit a record, switch tab); the render loop advances geometry. No fold runs continuously. GC pressure from fold allocations is a real risk only if that separation is broken, which is why the scar above exists. The architecture does not create the problem; violating the architecture does.
Log growth and the need for "git gc" — how long-term housekeeping actually works.
The analogy is correct but the conclusion lags the implementation. ad_seed.db is the
checkpoint: a versioned, verified snapshot of the application dictionary and seed data. At page load
the engine hydrates that snapshot into IndexedDB (ad_seed_v16) and then folds only the
op-log delta on top — not from op-zero. As the ledger grows, a System Admin opens the
System Monitor (role 0 only) and triggers "Reset demo/seed ERPs." That action
(SystemMonitor.resetSeedClients) does four things in sequence:
- Re-fetches the pristine shipped
ad_seed.dbfrom the server (hard-reload, no cache). - Snapshots every born-tenant row (
AD_Client_ID ≥ 17) from the live DB into a buffer. - Re-inserts those rows into the fresh seed (column-intersect,
INSERT OR IGNORE) — born tenants' business data survives intact. - Persists the rebuilt DB as the live IndexedDB blob and clears the op-log — the audit trail is reset (operator's call; the data is not).
The result is a clean checkpoint: demo/seed tenants return to pristine, born tenants keep their data, and the op-log is truncated to zero. This is checkpoint + log truncation in one operator action. No code change, no deploy, no architectural surgery. The "no server, deterministic fold" narrative holds because the seed is itself a deterministic artifact — signed, reproducible from op-zero if an audit ever demands it.
processIt / DocumentEngine.
iDempiere decides "is this a document?" by two checks: the Java model extends DocAction, and its
AD_Table carries the document columns (DocStatus, DocAction,
Processing, Processed). Then DocumentEngine.getValidActions(docStatus)
yields the legal action set for the current status — that is why C_Project (MProject)
has no Process button: it is not a DocAction model.Our engine reads the same intent from data, in two deliberate layers. (1) The gate — a record is processable iff its table is in
AdDocFsm.DOC_FAMILY (the ported
whitelist, ~30 tables: C_Order, M_InOut, C_Invoice, C_Payment,
GL_Journal, …) and the record carries a non-empty DocStatus value. That
DocStatus-value test is the model-agnostic stand-in for "AD_Table has a DocStatus column" — any
absorbed model with a DocStatus value lights up the chip automatically.
(2) The legal set — AdDocFsm.legalActionsFor is a faithful port of
DocumentEngine.getValidActions, reading real C_DocType flags (e.g.
iscanbereactivated).The load-bearing distinction — and why CRUD differs. Detect-from-AD can be made fully general: any table whose
AD_Table carries DocStatus+DocAction columns could light up the
chip. Execute cannot be made general: action consequences — completeIt's fan-out (shipment →
invoice → GL), voidIt/reActivateIt semantics — are per-model and must be extracted from
the real class, never invented. So legalActionsFor throws for an un-walked table rather than
defaulting to the generic union — that throw is the non-invent guard in code. An un-ported document table must
render Process as honestly absent or disabled, never with a fabricated transition.CRUD can be fully general because field editability lives entirely in the dictionary (
AD_Column.IsUpdateable, AD_Tab.IsReadOnly, AD_Table.IsView, role). Process
carries business consequences the dictionary does not contain. That is the exact line between S2B (CRUD — folds
from AD) and Process (gated to ported families).Postscript
Postscript · Where this sits — and what's bleeding
A reader asked the honest question every engineer should ask of a new architecture: what's the real difference from prior work, and am I on the bleeding edge — or just rephrasing event sourcing? A book that lives by the §FALSIFIER owes you the placement, risks included. So here it is, plainly.
The one real difference. The closest prior work (the
2018 ERP case study in the Introduction) does event
sourcing inside a conventional architecture: a server, business rules in application code, and an event
store sitting behind a materialized state that becomes the de-facto truth for queries. This engine collapses three
things that architecture keeps separate: (1) no store of record — the projection is always a fold, never
persisted-as-truth (Ch.1); (2) no server of record — each node folds locally, authority is
verifyChain, not a gatekeeper (Ch.11); (3) the rules are in the log — the business logic is the
declarative dictionary, folded like the data, and dictionary edits are themselves signed ops in the same log
(Ch.9). One line: they event-source the data; we event-source the data and the model, in one signed log,
with no server and no store of record.
What this is not. Two approaches look structurally adjacent; both are wrong comparisons, and conflating them with this engine is the most common misreading.
Not EAV. The Entity-Attribute-Value anti-pattern (Magento, WooCommerce product metadata,
parts of WordPress) stores all attributes as generic rows: (entity_id, attribute_name, value).
Reconstructing a single invoice from a naive EAV store can require 20–30 self-joins on the same table,
collapsing query performance and making selective indexing impossible at scale. The 5-relation kernel is
structurally different: each relation has typed, positioned columns; records are indexed on their natural
keys; and the fold output is a deterministic, typed projection — not an arbitrary string sitting in a
value column. EAV achieves flexibility by destroying structure; the kernel achieves it by
relocating structure — from the schema into the verb set (Ch.2). The surface resemblance (few
tables, flexible data) is superficial. The mechanisms are opposite.
The distinction matters because Datomic — cited above as the nearest neighbor — also uses a 5-tuple
(entity, attribute, value, transaction, op) that looks like EAV. It is not, for the same
reason: the tuple is typed, immutable, and indexed on all four axes simultaneously. A principled
immutable datom and a lazy EAV blob share no meaningful property beyond the word "attribute."
Not a view layer. Oracle APPS and SAP acknowledge that their underlying schemas are impenetrable (SAP: 452,000 tables, 7.3M fields) and respond by layering thousands of SQL VIEWs and synonyms on top. The abstraction is real for the programmer; the physical overhead is unchanged — storage, query planning, and I/O remain proportional to the underlying structure. This engine performs a physical reduction: the 5 relations are the on-disk structure written to SQLite. There is no shadow schema underneath. The complexity was not hidden; it was relocated from fixed table columns into runtime operational verbs — which are themselves first-class values in the signed log, not application code (Ch.2, Ch.9). That relocation is the move the view-layer approach never makes.
How bleeding-edge is each part — honestly. Bleeding edge is rarely one new atom; it is a novel composition of known atoms aimed at a problem nobody composed them for. Tiered without flattery:
- Established ground (not ours to claim): op-log as source of truth → Datomic (state as a fold over an immutable log, a decade old); signed append-only log → Git, Certificate Transparency, Merkle logs; deterministic replay → the event-sourcing canon (Fowler, Greg Young, Vaughn Vernon).
- Frontier of industry practice (we have company): no-server, local-first, edge-fold with peer-relayed signed ops → “Local-first software” (Kleppmann et al.), operation-based CRDTs (the PO-Log), Automerge / Yjs / ElectricSQL. This is where the field is genuinely moving — and we are in a crowd there, not alone.
- Genuinely distinctive (where the search came up empty): folding a declarative business dictionary —
the model itself — as signed ops in the same log as the transactions, so the running app, the rules,
and the to-the-cent accounting are all deterministic folds of one signed substrate, validated against a legacy
ERP oracle at
maxDiff=0c. Model-driven ERP is old (the AD is twenty); event sourcing is old; local-first is current — the intersection is what we couldn't find a precedent for. The nearest neighbor is probably Unison (content-addressed code-as-data), but nobody has pointed it at an ERP dictionary.
bigdecimal.js — op ordering, a clock, Map iteration order) silently breaks the fold; it is
correct-by-discipline, and discipline is fragile. Performance/scale is unproven — folding everything,
browser RAM (~300 MB needs sharding; one sql.js tab is a ceiling). Rule/verb evolution at decade scale is
untested — we solved version-travels-with-data better than the 2018 paper, but evolving the verb set and the fold
logic over years is ahead of us. No central authority means the security/conflict model is ours, and less
battle-tested than server + RDBMS + forty years of hardening.What to read to place yourself. Datomic (state-as-a-fold, the closest), Kleppmann's “Local-first software” (the no-server thesis), the operation-based CRDT / PO-Log literature (the distribution layer's theory), Unison (code-as-data-in-a-log), and Young/Vernon for the base pattern. Read those and you'll see exactly which moves are standing-on-shoulders and which one is ours.