Wall, Ledger, Card: Wiring a Data-Fabricating Model into a Production ERP
The test log holds one remarkably confident line: "Queried 2 accounting subjects."
The speaker was our LLM. That round, not a single tool call went through — the agent platform's whitelist had failed silently, and the tools never reached it. It didn't error. It didn't stall. It fabricated an answer, complete with two subjects, in the tone of someone who had just run the query.
The database showed zero calls that day.
This is the scene nobody skips when wiring an AI assistant into enterprise systems: at least on the model we have in hand, it does not stop when the tools are missing — it fabricates an answer and keeps walking. A pricier model, retries, more prompt engineering — would those help? We haven't verified it, and we wouldn't bet on it. Risk of this kind probably won't be eliminated by "smarter": the root isn't intelligence, it's that the talk, by nature, wants to tie itself into a neat bow.
So the real problem was never "how do we make the model answer correctly." It was:
How to build a system where a data-fabricating model, on the MCP main path and the attack surfaces we enumerated, cannot hurt the database — where that fact can be mechanically verified and the boundary stated plainly.
Session impersonation, file imports, scheduled jobs — not in that promise. The answer collapses into three words: wall, ledger, card.
This is not a tutorial; it's field notes. The integration window was three days: a single-file MCP gateway, the cheapest tier model we could get, an open-source manufacturing ERP. The card registry, the first versions of the wall, and the agent platform all predate those three days; the three days were the window for wiring it onto the production path and writing the discipline in stone — not the schedule for inventing it from zero.
1. The Lay of the Land
The base is ERPNext v16 — about 500 business objects (doctypes) after stripping the dead ones. On top, our in-house agent platform mounts tools over MCP; each agent knows which surface it may use by a nameplate (an agent→tool-surface mapping). The model is the cheapest GLM tier — deliberately: what only holds on an expensive model isn't architecture, it's burning money.
The user says "create a customer, Zhang San"; the assistant replies two lines:
New customer "Zhang San", type: company.
1 confirm / 2 cancel
Reply "1" and the database gains a row. The creator is not the assistant account — the assistant is read-only; real writes go through a separate executor account, and it acts only after confirmation completes. The audit ledger still records the assistant's orchestration chain: who released the row, who created the row — two ledgers. Reply "2" and nothing happens. That cut was forced by the direct-access hole in §4.
On the MCP main path and the enumerated entity/method keys: neither prompt-injection nor the model supplying its own confirmation words should land data that shouldn't be written. Side channels and the session layer: see the end.
Two exams verify this, with different jobs that don't fight:
| Exam | Tests what | Counts as green by |
|---|---|---|
| Census | Every object/method key in the registry hit once through MCP (one object or one method key = one "leg"; a leg is one test case) | The audit ledger (mushy talk but complete ledger rows: close by the ledger, no retry to overturn) |
| Scenarios | Hard-coded short dialogs: pinned surface text + a database row | Surface and database pass together |
In a scenario, if "1" comes back and no "created/cancelled" shows up, the executor replies 1 once more — what it sends is the confirmation surface, not a write request; the one-shot credential covers it, so this doesn't conflict with the no-blind-retry rule below. Counts are at the end. Here, only this: the census doesn't prove the model talks straight, and all-green scenarios don't prove the talk is accurate across five hundred objects.
2. The Ledger: Never Trust the Talk
The talk lies, and lies calmly. Beyond the opening fake subjects, two more flavors:
Phantom receipts. The model claims "confirmation received" when nothing arrived. A test bot that believed it and retried would send the same write twice; the write credential is one-shot, the second send burns it, and the real write fails. So it is nailed down: bots act only on audit rows; blind retries are forbidden. "Received it," "approval clicked" — no engagement with any of that.
Double-send residue. One turn, the tool call goes out twice: a mangled version gets rejected first, then a clean one passes. Two ledger rows. The consumer rule became "the last successful row wins" — if the last row is a rejection, that's not a win.
Three planes, each keeping its own record:
| Plane | Records what |
|---|---|
| Audit ledger | Gateway verdicts — release, reject, confirm-required, write-landed; one row each, with reason codes |
| Wire dump | The model's raw parameters, as sent |
| Database | The real rows and the creator |
When end-to-end goes red: audit first, then the dump, verdict last. One suite run scored 19/20; a manual six-step replay (no model — requests walked through the wall by hand) proved the wall chain intact; the red was the model resending without the parameter literal. The discipline: wall correct, model noncompliant → model jitter, not a product defect. With a reverse trigger: the same red shape recurring means turn around and suspect the wall. Jitter is random; a regular red means the wall or the protocol is wrong under some fixed condition. "Model jitter" is not a permanent shield.
Two small, hard rules:
- Through the wall ≠ landed in the database. Each keeps its own ledger; only after an independent WRITE row exists in the audit can green be told apart from green.
- Reconcile against the right ledger. After the domain split, each gateway writes its own audit file; grab the wrong ledger and even the green is fake.
And one more, often missed: a database assertion cannot tell who rejected. A base-platform permission denial and a wall rejection look identical in the database. With multiple roles sharing one database, "returned 200" proves nothing. Assertions must assert down to the plane: which role's audit ledger it landed in, whether the creator is that domain's executor account. An assertion that doesn't name the plane is no assertion at all.
3. The Card: Semantics Are Data, Not Code
The model has to deal with ~500 objects; someone has to tell it what each object is, its fields, what's required. That is the semantic card.
Domain research collects the cards; after culling the dead, merging, and gap-filling, the registry is finalized; dead surfaces go to a separate list. It later grew into six pieces: the blocklist, Chinese concept sentences, an alias table, the domain split, child-table snapshots, and a runtime merged registry. One sentence governs it all:
Semantics are data, not code. Add an entity, change a domain, cull a dead surface, fill in a concept — edit a data file and restart; the gateway code doesn't move.
Four pits forced that sentence out.
Dead-surface residue. A batch of doctypes exist in site metadata but have no table in the database — abandoned residue from upstream. Touch one and it's an HTTP 500; desktop, API, and AI blow up together. The fix isn't interception code but a blocklist: at startup they are pulled from the registry and the permission shadow is deleted in sync; when upstream fixes it, pull the entry and restart. Substring matching nearly caused friendly fire: the list had "Bank Clearance," but the database still has a real child table, "Bank Clearance Detail."
The wildcard swallowed the config. The night the domain-split file landed, startup logic merged by card-*.json and swallowed a pure config file as an entity card; instances crashed in a flock. The rule: before any new data file enters, ask whether some glob will claim it.
Forgot the restart. Data loads at startup. "I changed it, why no effect" — nine times out of ten, no restart.
Hand-copied contracts rot. The per-domain skills (operating manuals for agents) carried hand-copied summaries of high-frequency contracts in v1. The registry iterated once and the copies rotted silently. Now they are generated views of the registry; --check reports zero drift; hand-copying is banned. The registry is the single source of truth; two sources of truth equal none.
--check locks only internal derivations against the registry. It cannot lock upstream: after an ERPNext upgrade, fields, deprecations, and the dead-surface list all rot silently together. Upstream drift detection isn't built; it is on the books — the integration window can wire the registry, but who curates the registry long-term has no answer.
4. The Wall: Trust the Model a Little Less Every Version
The wall is the gateway's gatekeeper; every model request to the ERP passes through it. It governs four things: request shape, scenario whitelist, write confirmation, budget circuit-breaker. The philosophy in one line:
Every version trusts the model a little less than the one before.
The jurisdiction, stated up front. Shape, whitelist, budget — reads pass too; objects off the registry cannot be read out, and the assistant is read-only anyway. Confirmation hangs only on writes. It does not govern what happens after a read: not the retelling, not item names smuggled into it, not stored-type content in remarks. Excessive reads within the whitelist, sensitive fields fished out, database strings steering the model back — blank; see the end.
V1, two-phase. A write registers first and gets a one-shot credential → retold to the user → after consent, resent with the credential → the wall checks the digest and releases. It stops a malicious model, not an orderly fool: without user consent, the model can supply the confirmation words itself. Reproduced in black and white in tests.
V2, three-phase. Write authorization moved out of the LLM channel: on top of in-dialog confirmation, there had to be a registered document in the ERP that a human had clicked approved. Registration is always the assistant account; the "approved" state transition (colloquially, the flip) is allowed only from a session that isn't itself — the assistant approving its own registration gets a 417 from the base platform; the wall never even has to act. Malice without redemption power.
V3, back to single-factor. The trigger was a user's one line: "why can't I understand this?" Out-of-band approval meant logging in and hunting for the document. Confirmation became "non-empty counts"; the talk is two lines, "1 confirm / 2 cancel"; reply 1 and it writes. Anti-forgery rests on: a one-shot credential, valid within ten minutes (the upper bound), and "rejected" as a veto; plus dwell — a confirmation faster than the minimum interval is also rejected (the lower bound), against the model replying in milliseconds. Don't merge the two clocks into "dwell ten minutes"; the scenario that sleeps five seconds trips the lower bound. Out-of-band approval and non-empty-writes are two extremes; the middle forms — the confirmation echoing a digest a human can scan, a single-click confirmation link — never made the integration window's schedule, and were never tried.
The document stays on as a record, with the "rejected" veto; nobody is asked to click in the system anymore. This defends against the model forging consent; it does not defend against the channel being impersonated: a hijacker's 1 and a real user's 1 look identical to the wall; if rendering is poisoned to display "1 confirm," the wall cannot tell. The price is marked on the ledger: the session layer got no hardening in the window.
Four iron rules, each with a story.
Permissions are the wall. The assistant account bypassed the gateway and hit REST directly: create, modify, all 200 — the confirmation hook hung only on MCP. The fix wasn't a blocking script: the assistant was trimmed to read-only, writes go through the separate executor account, and without it configured, refuse. The wall's domain of definition must sit in the permission layer; hooks only guard the doors they can see. The hole was found by probing; there may be doors not yet probed.
The gate obeys the same rules as the model. The sales-order chain once ran six rounds with zero passes. At confirmation the model flattened a child-table array into a string — not tampering; it genuinely couldn't hold the array. Registration held an array, confirmation held a string, the digest went red; the model tried harder with the string — a type deadlock. The fix: both sides normalize stringified child tables back into arrays before computing the digest. An encoding ambiguity is not drift; a genuinely changed value is still rejected. The protocol absorbs the peripheral's unreliability; it does not punish it.
The receipt is external memory. The confirmation receipt echoes the full payload for the model to copy. Demanding "resend verbatim" is dangerous — the model drifts back to its own previous draft. Anything that must be retold gets the full literal embedded in the talk.
A failed flip must cry out. The controller once allowed only approved→executed; a "1" takes pending→executed, and the 417 was swallowed silently — the write landed, the document hung in pending forever. Fixed the controller, and added a "flip failed" audit row. A silent failure costs more than a failure.
5. Four Expensive Silent Failures
The split-and-merge went three rounds (roles vs. business surfaces, skills vs. nameplates, credential numbers). What is expensive is "one layer is lying while the others keep reporting green." Of the four, silently eating the old wall and the same-name tool surface clobbering the last corner are one family: the tests are green, the world has changed.
The credential war. The general-domain tests went red in a row. First suspect: the model vendor; half a round, nothing. Then each credential in the config was fired at the database alone: 401. The wall's audit still said release (it checks the whitelist, not the base ticket); the talk offered one vague "authentication error." Of three layers, exactly one was telling the truth; attribution without per-layer verification lands wrong, every time.
The assistant account was one key shared by several parties: whoever re-signs gets a working ticket, everyone else's dies. We re-signed, fixed ourselves, and killed the ticket another line had just synced; they wrote "the key was rotated out-of-band, not this repo's doing" — that was us. The discipline: on the next 401, try the other tickets in the config before re-signing. The census now has its own user reusing the role permission shadow; the general assistant and the shared environment (colloquially, the cage) still share a key, unresolved.
Silently eating the old wall. The research directory defaulted to the main checkout. After a second checkout's new instances came up, they silently ate the main checkout's old wall — new prompts against old wall behavior, tests all green, but the write policy was wrong. A non-main checkout must be named explicitly in config. A silent misconfiguration costs an order of magnitude more than an error.
The same-name tool surface clobbering the last corner. The general surface stood first; six business surfaces were split out later, each re-registering per domain. The registration name is a global single point; the last one registered wins — so for every leg run, the general surface got clobbered into that leg's domain surface. After the run, tests all green, but the "std registration" actually pointed at another domain. No error, no conflict notice — just a nameplate silently rewritten. The fix: independent surfaces, the std pinned in a PRE phase, auto-restore after the run. The family trait: the wall didn't break, the nameplate changed; and the green in the ledger hasn't yet heard the world moved.
The tests deleted a real customer. Cleanup once deleted by name — and hit a customer the user had created that very afternoon. He asked "creation succeeded but I can't find it"; two hours, three people, two answers, and one of them was our test. Cleanup became snapshot-difference: record the name set at start, delete only this run's additions. In an idempotency test, the deletion count should be zero.
One more from the same family: with the nameplate unpinned, the model called another party's surface — the wall never broke, and green cannot explain "the world already changed." Skills promising objects the wall doesn't recognize, not sweeping staging accounts before parallel runs, a resuming reconciler reading the full history at start — registered in the other ledger; not unfolded here.
6. Taming the Model and the Scenario Library
Sample: one cheapest-tier GLM, three days of integration, limited turns. Field observation, not law; the lower bound holds only within the enumerated attack surfaces: on those surfaces, the least obedient peripheral can be fenced in, and a better-behaved one very likely too. The enumeration was drawn from this model's behavior; a smarter attack may land outside the enumeration — that is not in the lower bound.
Hard pins: tool parameters must be object literals (a string silently becomes an empty object); one call per turn; an empty list is a real receipt, not a failure.
Reproduced degradations: changing its story at confirmation and re-registering (a new credential); flattening arrays into strings; hallucinating "go click Approved in Desk"; inventing "ITEM-0001" as a shortcut; writing "1 confirm" as "1. confirm". There is a healthy window of roughly ten rounds; compliance fluctuates overnight, attribution unsettled.
The master rule: the model is an unreliable peripheral. Ask when in doubt; no fabrication, no self-supplied confirmation; prompts, gate wording, and persona lines share one source; the protocol leaves no room for improvisation — every improvised flourish ends up as an unattributable red row in the ledger.
The scenario library: declarative steps against "say this line → expected talk + expected database state"; adding a scenario costs zero code. Teardown: the ledger counts only this run's additions, pending documents cleared, snapshot-difference cleanup. Real defects caught in the first round: the relay line drifted in transcription (pinned to the literal); a "1" reply so fast it tripped the dwell lower bound (sleep five seconds — the bot learns to act human first); an old "defaults need confirmation" clause fighting the new protocol and bypassing registration. The scenario library is first a defect probe: green isn't the output; the red it catches is.
Grit: the audit flush may lag the response by two seconds; leave half a second of tolerance at the dwell boundary; a fresh session number each round; don't assert a truncated "rejected" ("rejected by someone" won't match).
7. The Wrap
The user says one thing
→ agent platform (one nameplate per surface, one skill per agent)
→ MCP gateway (six business surfaces + the census surface)
→ the wall (shape / whitelist / write-confirmation / budget; reads pass the first three; retelling ungoverned)
├ read: assistant read-only → ERP
└ write: one-shot credential (≤10 min) + dwell floor → confirmed, then the executor account lands it
→ ERPNext (creator = executor account; audit = the assistant's orchestration chain; pending documents kept as records + the "rejected" veto)
Green trusts only audit rows and the database, never the talk.
The two exams (scale and policy; what each tests and what counts as green: §1)
| Exam | Scale | Policy |
|---|---|---|
| Census | ~1,376 legs (entities + method keys) | Zero hard failures (wrong codes / transport dead / missed hits / wrongful writes); of these, 506 legs (~one third) had complete ledgers but mushy talk, closed by the ledger |
| Scenarios | 84 items (plus a smaller demo/role matrix) | Talk + database, both green across two rounds |
Coverage is the MCP main path and the enumerated keys only. Imports, scheduled jobs, WebSockets were not hit one by one. REST direct access is not in the census; the §4 hole was found by probing, and the permission layer has its own probe now that it is patched.
The ledger is the only truth. The talk fabricates, the model jitters, layers cross, the platform degrades silently — only each layer's audit rows let you still say "green" after hard failures hit zero. In full: on the MCP main path and the enumerated keys, the audit ledger is green; the ~one-third mushy talk is not in that sentence; scenarios are a different exam.
Two more for anyone building this kind of system: the hardening direction is not a better model, it is moving trust out of the model; and assertions must name the layer — result codes lie.
What the wall doesn't hold
- Read-side talk: it can keep unregistered objects from being read out; it cannot police smuggled retellings or fishing reads within the whitelist.
- Conversation quality: it protects the database, not the correctness of the talk.
- Session and identity: single-factor stops forged consent, not channel impersonation.
-
Upstream drift:
--checkcannot lock the database surface after an ERPNext upgrade. - Shared keys: the census has its own; the general assistant and the cage still share one.
- Silent platform degradation: a missing whitelist or trailing junk on a config line downgrades without an error; unfixed.
These six are not one kind of thing. Protecting the database but not the talk is a design boundary — it was never in the promise. Session hardening, shared keys, and silent degradation are debts the three-day window couldn't schedule; schedule them and they get paid. Fishing reads within the whitelist, upstream drift, and who curates the registry are registered questions without answers — being on the books is not a solution. Anyone copying this design copies all three along with the wall.
The wall isn't eternal dogma either. If someday, on the same enumerated keys, the audit ledger and the database keep yielding reproducible evidence of "zero fabrication, zero out-of-bounds" — trusting the ledger, not the talk — then the confirmation layer owes a re-evaluation: downgraded, reshaped, or removed. Until that evidence exists, the confirmation layer stays at its current price. The wall's reason to exist is to be refuted by data, not kept by habit.
That opening line — "Queried 2 accounting subjects" — is still lying in the test log. Every time someone asks "why not let the model touch the database directly," it comes back out.
On the model we have in hand, it does not stop when the tools are missing.
Your system has to stop for it.
A note on translation: this piece was written in Chinese; all quoted speech — the model's "Queried 2 accounting subjects," the user's "why can't I understand this?", the other team's log line — is translated, and the test logs and audit rows are the source of record. "Wall, ledger, card" (墙、账、卡) is the original mnemonic.
Top comments (0)