The problem this solves

Almost every number in the general ledger arrives from a subledger, not from a person. Receivables raises the invoice and recognises revenue; Inventory relieves stock into cost of goods; Payables books the supplier invoice; Payroll accrues the wages; Assets runs depreciation. The manual journals are the small minority — and they are the ones that cause trouble, because they are the only ones a human composed.

The rule that catches people is that a journal must balance twice. Total debits equal total credits, obviously. But it must also balance within each balancing segment: every company has to net to zero on its own. A journal that balances in total but not by company looks completely fine to the person entering it, and the GL will not reject it — it silently generates intercompany due-to/due-from lines to make it legal. Those generated lines are real intercompany balances that consolidation then has to eliminate, created by a journal nobody thought was intercompany.

Audience

GL accountants and close teams who post and review journals. EPM and consolidation teams who inherit intercompany balances they did not expect. Anyone explaining to a business user why their two-line recharge became a four-line journal.

Six automatic sources and two manual ones

SourceCategoryPosts
NLQ query: “Show journals for NYC Flagship in August” or “Which journals are unposted?” — DeepSeek resolves entity, period and source
AR ReceivablesSales InvoicesDr Receivables / Cr the three revenue accounts
INV InventoryCost of GoodsDr Merchandise Cost / Cr Inventory
AP PayablesPurchase InvoicesDr operating costs / Cr Payables
PAY PayrollPayroll AccrualDr Store Labor / Cr Payroll Liabilities
FA AssetsDepreciationDr Depreciation / Cr Accumulated Depreciation
TRE TreasuryInterest & TaxDr Interest and Tax / Cr the matching accruals
MAN / SPRAdjustment, ReclassWhatever a person composed — and the only ones that wait for approval

Batches, lines, and two balancing verdicts

  • Batch list by source with status, category, line count and value
  • Line detail with the full six-segment code combination on every line
  • Balancing panel reporting the total verdict and the per-company verdict separately, with each company’s net
  • The trap: an intercompany recharge that balances in total but not by company, with a button that applies the lines the GL would generate — tagged and highlighted so they are unmistakably not what was entered
  • Unposted value stated in dollars, because that is what will be missing from the trial balance

Between the chart of accounts and the trial balance

Chart of Accounts (UC16) defines the combinations these journals post to — and every line generated here is asserted to pass its eight cross-validation rules. Trial Balance & Close (UC18) aggregates the posted ones and turns the unposted ones into a close blocker. Downstream, the intercompany lines the GL generates here are exactly what IC Eliminations (UC8) has to match and eliminate.

Recommended integration points

  • Journal training: the balancing-segment rule is far easier to teach with a live example than with a policy document
  • Intercompany root-cause analysis: when consolidation asks where an unexpected IC balance came from, this is often the answer — a manual journal that crossed companies
  • Close readiness: the unposted value on this page is the number that becomes a blocker on the next one

The numbers

8
Journal sources
2
Ways a journal must balance
6
Segments on every line
100%
Of lines pass cross-validation
$0.0002
Cost per NLQ query
2
Lines the GL adds by itself

The honest checklist

  • โœ“Your consolidation keeps finding intercompany balances nobody remembers creating
  • โœ“You need to explain the balancing-segment rule to people who post journals across companies
  • โœ“Unposted journals have ever surprised you at close — and you want the dollar value visible before day five
  • โœ—Your ledger has a single company — there is no balancing-segment problem to demonstrate
  • โœ—You need journal approval workflow modelled in depth; this shows posted versus unposted, not a multi-stage approval chain

Try it now

The live demo generates a period’s journals for any of 44 companies and checks the balancing on every one.

Launch Journals & Balancing โ†’ Start a Lab engagement

Things to try

  • Press “Journal that balances in total but not by company”, read the balancing panel, then press Apply the generated lines — two lines you never entered, tagged GL GENERATED, and now the journal is legal
  • Type “Which journals are unposted?” and look at the excluded value — that is precisely what will be missing from the trial balance on the next page
  • Click any automatic batch and read the code combinations: every line carries all six segments, and every one of them passes the UC16 rules
HOW WE BUILT IT

The GL does not reject it — it fixes it, and tells nobody

If a journal did not balance at all, the ledger would refuse it and the problem would be over in seconds. The genuinely awkward case is the journal that balances perfectly in total while leaving two companies out of balance against each other. The accounting is not wrong — a corporate IT recharge really does debit one company and credit another — but a general ledger cannot hold a company whose books do not net to zero, so it generates the missing intercompany lines itself.

That behaviour is correct and it is also where the surprise lives. The person who entered a two-line recharge sees a four-line journal. Consolidation sees intercompany balances arriving from a journal that was never flagged as intercompany. And because the lines are system-generated, there is no obvious owner to ask. Showing the imbalance and the generated lines side by side turns a mysterious downstream symptom into an obvious upstream cause.

Subledger posting model → batches → balancing check

Company + year + period + optional source (from NLQ or the filters)
1
Drive the amounts
genIS(entityId, year, "Actual")
is[period]["A_" + naturalAccount] ร— 1000
  • Every line amount comes from the FP&A income statement, converted $K → dollars
  • That is what makes the trial balance in UC18 tie to the dollar rather than approximately
2
Post by subledger
genJournalBatches()
POSTING_MODEL: Dr expense / Cr the matching liability
  • Six automatic sources, each a genuine double entry with a balance-sheet counterpart
  • Cost centre and product defaulted per account so every line passes cross-validation
  • A manual accrual is left Unposted in roughly a third of periods — the close blocker
3
Check the balancing
checkBalancing(lines)
total Dr = Cr  AND  every company nets to zero
  • Two independent verdicts, because a journal can pass one and fail the other
  • Where a company is out of balance, derives the due-to/due-from line the GL would generate
Renderer
journals.html
  • Batch list, line detail with full code combinations, balancing panel with per-company nets
  • The trap journal and the button that applies the generated lines

App UI โ€” component breakdown

ComponentBehaviour
FiltersCompany, year, period, and source — source filters the batch list rather than regenerating it
KPI rowBatch count, posted vs unposted, total debits, and the dollar value excluded from the trial balance
Batch listSelectable cards showing id, status, source, category, line count and value
Line detailFull six-segment combination, account, description, debit and credit, with a totals row
Balancing panelTotal verdict, per-company verdict, each company’s net, and — when out of balance — the explanation plus the apply button
Generated linesRendered inline, highlighted, tagged GL GENERATED so they are never mistaken for entered lines

Where each number actually comes from

AR   Dr 11101 Receivables        Cr 40001/40002/40003 Revenue
INV  Dr 50001 Merchandise Cost   Cr 11201 Inventory
AP   Dr 50002/60002/60003/60004  Cr 21001 Payables
PAY  Dr 60001 Store Labor        Cr 21201 Payroll Liabilities
FA   Dr 70001 Depreciation       Cr 12009 Accumulated Depreciation
TRE  Dr 80001 Interest           Cr 21301 Accrued Interest
     Dr 90001 Tax Provision      Cr 21401 Income Tax Payable

Each is a real double entry with a balance-sheet counterpart, which is what makes the trial balance balance rather than merely appear to. The P&L side of every one of these is genIS(): the AR batch credits exactly the revenue the FP&A module reports for that entity and month, the FA batch debits exactly its depreciation, and so on. Nothing is invented and nothing is rounded into place.

Cost centre and product are defaulted per account so the resulting line is legal — a P&L account gets a real cost centre appropriate to the entity type, a balance-sheet account gets 0000, and an account flagged productRequired gets a product family. That defaulting exists so the generated ledger cannot contain a combination UC16 would reject, and the test suite asserts exactly that.

Total, and then within every company

checkBalancing(lines) โ†’ {
  balancedInTotal:   |ฮฃ debit โˆ’ ฮฃ credit| < 0.5,
  byCompany:         { '101': net, '901': net, ... },
  balancedBySegment: no company nets away from zero,
  generated:         the due-to / due-from lines the GL would add
}

// The rule that decides which account:
//   company nets DEBIT  โ‡’ it owes the other company  โ‡’ 23001 Intercompany Payable (credit)
//   company nets CREDIT โ‡’ it is owed                 โ‡’ 13001 Intercompany Receivable (debit)

The generated lines are derived, not hardcoded: the account follows the sign of the imbalance, the counterparty is the company with the opposite-signed net, and the amount is the imbalance itself. Applying them makes the journal balance by segment and keeps it balanced in total — asserted in the test suite, because a “fix” that broke the total balance would be worse than the original problem.

Worth saying plainly to clients: those two lines are not cosmetic. They are intercompany balances that will appear in both companies’ ledgers, flow into consolidation, and have to be matched and eliminated in FCCS. A recharge journal that nobody thought of as intercompany has just created intercompany work.

The quietest way to close on an incomplete ledger

Automatic subledger journals post as part of the transfer. Manual journals wait for approval — and an approval that has not happened by day five is the most common reason a period cannot close honestly. The demo leaves a manual accrual (services received not invoiced) unposted in roughly a third of periods, deterministically, so the behaviour is reproducible rather than random.

An unposted batch is excluded from the trial balance. That is the correct behaviour and it is also the trap: the trial balance still balances, still looks complete, and is simply missing activity. The KPI row therefore states the excluded value in dollars rather than just a count, because a count of one sounds trivial and the dollars usually are not. UC18 takes the same fact and turns it into an explicit close gate with the offending companies named.

Entity, period, and an eight-value source enum

The journals schema is {intent, entity, year, period, source, confidence}. The source enum maps the way people actually speak — “supplier” and “vendor” resolve to AP, “salary” and “wages” to PAY, and “unposted”, “adjustment” or “does not balance” all resolve to MAN, because in practice every one of those questions is about a manual journal. Nothing about a journal’s content reaches the model: not amounts, not descriptions, not counterparties. The model picks a filter; the ledger provides the data.

Cost model — cost per query

ComponentDetailCost
LLM input tokens~720 tokens (schema block + few-shot examples + query)$0.000101
LLM output tokens~45 tokens$0.0000126
Journal generation + balancing checkClient-side, deterministic, once the query resolves$0
Total per query~$0.0001
With 2× safety margin~$0.0002

The source alias table is the bulk of the prompt. Generating a period’s batches for one company and running the balancing check is a few milliseconds of client-side work.

Key files

FileRole
epm-nlq-src/assets/epm-erp-gl-data.jsJOURNAL_SOURCES, POSTING_MODEL, genJournalBatches(), checkBalancing(), genIntercompanyRecharge() — double-entry batches driven by genIS(), and the balancing engine that generates due-to/due-from lines
epm-nlq-src/pages/journals.htmlUI: NLQ bar, entity/year/period/source filters, batch list with status, line detail with full code combinations, the balancing panel, and the intercompany-recharge trap
epm-nlq-src/assets/epm-data.jsShared ENTITIES, PERIODS and genIS() — the P&L amounts the journals post, and therefore what the trial balance reproduces
epm-nlq-src/assets/epm-fccs-data.jsgenConsolidationBS() — the balance sheet side, shared with the FCCS and ARCS modules
functions/api/nlq-query.jsShared NLQ endpoint; this use case uses useCase: "journals"

Why the ledger is generated from genIS(), not beside it

The tempting shortcut is to generate plausible journals independently and let the trial balance be roughly right. That would have broken the one claim this module exists to make. Instead every journal line amount is driven by genIS(), so the posted trial balance reproduces the FP&A income statement to the dollar — asserted by unit test across multiple entities and periods. It is the same discipline the FCCS module applies when it constructs a local balance sheet that balances before translation: if the synthetic data does not tie, the demo teaches the wrong lesson.

Tech stack — every tool in this build

LayerToolWhy
Data layerepm-erp-gl-data.js (vanilla JS)Real double-entry generation and a balancing engine — the LLM only resolves the filter
LLMDeepSeek V3Shared NLQ endpoint; a five-field schema with an eight-value source enum and a spoken-language alias table
ChartsNone — journals are a table, and a chart would obscure the debits and creditsShared library with the other kits
Edge hostingCloudflare PagesStatic file, no server compute needed for this use case
BuildEleventy v3.1.5Copies epm-nlq-src/pages/ and epm-nlq-src/assets/ to _site/ verbatim

Known attack surfaces

ThreatMitigation in this build
A journal that balances in total but not by company is treated as balancedcheckBalancing() computes both the total and the per-company net, and the panel reports them as two separate verdicts. A journal can pass one and fail the other, which is exactly the case the trap demonstrates
Generated intercompany lines are invisible to the person who entered the journalThe demo renders them explicitly, tagged GL GENERATED and highlighted, with a note that they become real due-to/due-from balances FCCS must eliminate — the connection people miss
Unposted journals are assumed to be in the trial balanceThey are excluded, and the KPI row states the excluded value in dollars. UC18 turns the same fact into a close blocker
A demo journal could itself be illegalEvery generated line is asserted in the test suite to pass all eight cross-validation rules from UC16 — the ledger could not have posted a combination the chart of accounts forbids

Guardrails — what prevents bad ledger math

  • Every batch balances in total and by company: asserted across all generated batches, so the only unbalanced journal on the page is the one deliberately constructed to teach the rule
  • Amounts come from genIS(), not from a random generator: that is what lets UC18 prove the trial balance ties to the FP&A income statement to the dollar
  • Generated balancing lines are computed, not hardcoded: checkBalancing() derives the account (due-to when a company nets debit, due-from when credit), the counterparty and the amount from the actual imbalance
  • Manual and automatic sources are distinguished everywhere: subledger journals post automatically; manual ones carry an approval state, which is why the unposted batch is always a MAN source

Who is asking, and what are they allowed to see?

The demo answers neither question — it has a cookie gate and no notion of a user. In production these are the two questions everything else rests on, and they have different answers: authentication is who you are, authorization is what you may see. Corporate SSO settles the first. Only Oracle ERP Cloud (Fusion) can settle the second, and the single most important rule in this section is that this application must never become the place where that decision is made.

The rule that governs every choice below: a user must see exactly what they would see by logging into Oracle ERP Cloud (Fusion) directly — no more, and no less. If this tool can surface a number the user could not retrieve themselves, it has become a privilege-escalation path, and it will be found in the first access review.

9.1 · The identity chain, end to end

Finance user opens the tool in a browser — no local account, no password held here
1
Corporate identity provider
Entra ID · Okta · OCI IAM
OIDC Authorization Code + PKCE  (or SAML 2.0)
  • MFA and Conditional Access are enforced here — device compliance, location, risk signals
  • Returns an ID token (who the user is) and an access token (what they may call)
  • Group membership arrives as a claim; the application never handles a password
2
Application session
validate, never trust
  • Verify signature, issuer, audience and expiry against the IdP’s published keys
  • Read the group claims — there is no local user table and no local role table
  • Short-lived access token with refresh-token rotation; session timeout set to the data classification
Pattern A — identity propagation
OAuth 2.0 token exchange (on-behalf-of)
  • The API is called as the user
  • Fusion enforces its own security natively
  • Audit trail names the real user
  • Preferred where the API supports it
Pattern B — service account + filtering
one read-only integration account
  • The application becomes the enforcement point
  • Entitlements fetched separately, applied in one audited place
  • Simpler and cacheable — and a filtering bug is a data breach
3
Fusion security
roles + data access sets
  • Roles: job roles (General Accountant, General Accounting Manager) composed of duty roles, plus data roles
  • Data level: Data access sets (which ledgers, which balancing segment values) and business-unit security decide whose journals a user may open
  • Group → role mapping lives in the platform, not in this application
Result, filtered to this user
journals.html
  • The user sees exactly what they would see logging into the source system directly — no more
  • Every query logged against the real end user, never a shared account

9.2 · Federating the corporate identity provider

Oracle ERP Cloud (Fusion) does not replace your directory — it trusts it. Fusion is federated with the corporate IdP so authentication happens where it already happens, under policies security has already written.

Identity providerProtocolNotes
Microsoft Entra ID (formerly Azure AD)SAML 2.0 or OIDCThe common case. Conditional Access, MFA and device compliance are enforced at Entra and inherited automatically. On-premises Active Directory federates through Entra Connect rather than being integrated directly.
OktaSAML 2.0 or OIDCSame pattern; Okta groups drive ERP roles through SCIM provisioning.
OCI IAM (identity domains)NativeAlready present with Oracle ERP Cloud (Fusion). Can be the primary IdP for a smaller estate, or a federated spoke of Entra/Okta for a larger one.
AD FSSAML 2.0Still seen where the estate is not yet cloud-first. Works, but you inherit the on-premises availability of the token service — if AD FS is down, nobody logs in.

For the browser application itself, use OIDC Authorization Code flow with PKCE. Not the implicit flow, which is deprecated and leaks tokens through the URL, and never a resource-owner password grant — a finance tool should not be capable of handling a password at all.

9.3 · From group membership to ERP roles

Roles are granted to groups, never to individuals, and the groups come from the directory. That one discipline is what makes joiner/mover/leaver work without anyone having to remember this application exists.

Entra ID group                    โ†’  ERP role / entitlement
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
FIN-EPM-Analysts                  โ†’  General Accountant (inquiry)
FIN-EPM-Controllers-EMEA          โ†’  General Accounting Manager + EMEA data scope
FIN-EPM-Admins                    โ†’  Financial Application Admin
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Provisioned by SCIM. Remove the user from the group and the
entitlement disappears on the next sync โ€” including here.

For this use case the relevant native entitlement is: General Accountant (inquiry). Critically, this tool must be granted no journal create, approve or post privilege.

9.4 · The architectural decision: who enforces?

This is the choice that determines whether the deployment is defensible. Both patterns appear in the diagram above; the difference is where the security boundary actually sits.

Pattern A — identity propagationPattern B — service account + filtering
HowThe user’s token is exchanged (OAuth 2.0 on-behalf-of) for one scoped to the ERP API; calls are made as the userA single read-only integration account calls the API; the application filters the results
Enforcement pointOracle ERP Cloud (Fusion)This application
Audit trail showsThe real end userThe service account — you must log the real user separately
Failure modeToken plumbing is more complex; per-user rate limits applyA filtering bug is a data breach, and the entitlement copy drifts from reality
VerdictPrefer this wherever the API supports user-token authenticationAcceptable with discipline: narrowest possible service account, filtering centralised in one tested place, real user in every log line

The shortcut to refuse. Pattern B built with a Service Administrator account and no filtering at all is the most common way this gets delivered, because it works perfectly in UAT — testers are usually over-entitled, so nobody notices that everyone can see everything. It fails at the first access review, and by then it is in production with real users depending on it.

9.5 · Data-level security is the part that matters

Role membership decides whether a user can open the application. It does not decide which rows they get back, and confusing the two is the most expensive mistake available here.

  • For this use case: Data access sets (which ledgers, which balancing segment values) and business-unit security decide whose journals a user may open.
  • Apply it before aggregation, not after. Filtering a total that has already been computed across entities the user cannot see still leaks the total.
  • The NLQ layer needs its own check. Layer 4 already validates that the resolved point of view uses approved members; production adds a second test — that the resolved POV sits inside this user’s scope — and it runs before the data call, not after. A natural-language interface is very good at asking for things politely; the authorization check must not care how the question was phrased.
  • Fail closed. If entitlements cannot be resolved, return nothing and say so. An empty result is a support ticket; a permissive default is an incident.

9.6 · Provisioning, sessions and the leaver problem

  • SCIM provisioning from Entra or Okta into the Fusion security console, covering joiner, mover and leaver. The mover is the case people forget — somebody changing region should lose the old scope, not accumulate both.
  • No local user store. If this application keeps its own copy of who may do what, a leaver keeps access until somebody remembers to update it. Nobody ever does.
  • Short-lived access tokens with refresh-token rotation; align session timeout with the data classification rather than with convenience.
  • MFA and Conditional Access at the IdP — not reimplemented here. Device compliance and location policy come free with federation.
  • Quarterly recertification of both the groups that grant access and the service account’s own entitlements, evidenced and signed.
  • Break-glass access is a named, monitored, time-boxed account — never a shared credential in a password manager.

9.7 · What this means for Journals & Balancing

ConcernAnswer for this use case
Native entitlement requiredGeneral Accountant (inquiry). Critically, this tool must be granted no journal create, approve or post privilege
Data-level controlData access sets (which ledgers, which balancing segment values) and business-unit security decide whose journals a user may open
Use-case-specific sensitivityJournal descriptions and supplier or customer names routinely contain commercially sensitive detail and occasionally personal data — the schema in this build carries none of it to the model. The read-only posture is itself a control: an assistant that can post journals is an assistant that can commit fraud.

9.8 · Security configuration checklist

  • ✓Oracle ERP Cloud (Fusion) federated with the corporate IdP over SAML 2.0 or OIDC; the cookie gate removed entirely
  • ✓Browser app uses OIDC Authorization Code + PKCE — no implicit flow, no password grant
  • ✓MFA and Conditional Access enforced at the IdP, not reimplemented in the application
  • ✓Roles granted to directory groups, never to individuals; SCIM covers joiner, mover and leaver
  • ✓Enforcement pattern chosen deliberately — Pattern A where the API supports it, or Pattern B with filtering centralised and tested
  • ✓Data-level security applied before aggregation, and the resolved POV checked against the user’s scope before the data call
  • ✓No local user table and no local role table anywhere in the application
  • ✓Every query logged against the real end user, even when a service account makes the call
  • ✓Authorization failures fail closed and are logged as security events rather than swallowed
  • ✓Quarterly recertification of access groups and of the service account’s own entitlements
Talk through your identity model → Back to the demo

From demo to a governed enterprise deployment

Everything above runs on synthetic data, a public LLM API key, a cookie gate, and no audit trail — deliberately, so the mechanics are inspectable. Taking Journals & Balancing to production is not a rewrite; the 4-layer pipeline and the data-layer contract survive intact. It is a controlled-change program across six workstreams: architecture, LLM platform, security, SOX/audit, environment promotion, and operations. This section is the checklist we run with clients.

The one rule that matters most for this use case: the general ledger is the system of record for the numbers on this page. In production the ERP computes and this layer retrieves and explains — it never re-derives a balance, never posts, and never becomes a second place where a number can be right. A read-only window onto the ledger is defensible; a shadow ledger is not.

10.1 · Production reference architecture

Finance user · corporate SSO (OIDC/SAML + MFA) · ERP role claims
1
Edge / API Gateway
WAF · rate limit · identity
  • Terminates SSO, validates the session, attaches the user’s ERP responsibilities and data-access sets to the request
  • Rate limits per user, blocks anonymous access, scrubs PII patterns before anything reaches the orchestrator
2
NLQ Orchestrator
the 4-layer pipeline, hardened
L1 guardrails → L2 grounding → L3 LLM adapter → L4 eval + fallback
  • L2 grounding reads the live chart of accounts and segment value sets on a schedule — not a hardcoded schema
  • Only the schema + user query go to the model; ledger amounts never leave the data layer
  • L4 rejects any segment value outside the approved value sets and falls back to the deterministic parser
Oracle OCI Generative AI
same tenancy as ERP Cloud
  • Data stays inside the OCI boundary
  • Natural fit when Fusion ERP is already in OCI
Azure OpenAI / AWS Bedrock / Vertex AI
private endpoint, zero retention
  • Use the hyperscaler the org already governs
  • Enterprise DPA, no training on prompts
Self-hosted open weights
VPC / air-gapped
  • For regulated or sovereign ledgers
  • Highest control, highest run cost
3
ERP Data Layer
Oracle ERP REST / BI Publisher
  • Oracle ERP Cloud — journal headers, lines, batches and statuses via the ERP REST API or a BI Publisher extract; subledger accounting (SLA) owns the derivation of every automatic journal
  • Least-privilege read-only integration user, scoped to the required ledgers and data-access sets, credential in a vault and rotated
  • Results filtered to the requesting user’s own ledger and data-access-set security before rendering
4
Audit & Observability
append-only
  • Every query logged: user, timestamp, raw query, parsed intent JSON, model + prompt version, ledger/period returned, latency, cost
  • Exported to the SIEM; retained per the SOX evidence schedule
  • Dashboards for fallback rate, eval pass rate, guardrail hits, p95 latency, spend
Rendered result + evidence trail
journals.html
  • The parsed JSON is shown to the user as the explanation — the same line the demo prints today
  • Every number on screen drills to a journal line an auditor can reproduce in the ERP

10.2 · Choosing the LLM platform

The demo’s DeepSeek call is a placeholder for a single adapter, callLLM(system, user), behind Layer 3. Swapping the provider changes one function and zero business logic. Pick the platform the organisation already governs — the security and procurement review is the long pole, not the integration.

OptionChoose whenData posture
Oracle OCI Generative AI (Cohere Command, Llama)Fusion ERP already lives in OCI; you want one cloud boundary and one contractPrompts stay in the OCI tenancy; no training on customer data; dedicated AI clusters available for isolation
Azure OpenAI ServiceMicrosoft-first finance estate (Entra ID, Purview, Sentinel already in place)Private endpoint, regional deployment, zero-retention by default under the enterprise agreement
AWS Bedrock / Google Vertex AIThe org’s landing zone is AWS or GCP; VPC endpoints and IAM already auditedVPC/PSC private access, no data used for training, CloudTrail/Cloud Audit Logs integration
Direct enterprise API (Anthropic, OpenAI)Fastest model access; acceptable when a zero-data-retention agreement and DPA are signedZDR endpoint, SSO-managed keys, SOC 2 report on file
Self-hosted open weights (Llama, Mistral, Qwen via vLLM)Sovereign or air-gapped requirements; the ledger’s data classification forbids external inferenceFull control; you own patching, eval, and capacity — budget for an MLOps owner

Put a model gateway in front of whichever you choose (Azure API Management, OCI API Gateway, Kong AI Gateway, LiteLLM, or Portkey): it owns key custody, per-team spend caps, routing and fallback between models, prompt/response logging, and lets you retire a deprecated model without touching the application.

10.3 · Security controls

ControlImplementation
Identity & accessCovered in full in section 09 — corporate SSO, group-to-role mapping, and the decision about who enforces data-level security. Listed here because it is a production gate, not because it is optional.
Integration userOne read-only ERP integration user per application per environment, scoped to the required ledgers, no interactive login, credential in a vault (OCI Vault, Azure Key Vault, HashiCorp Vault), rotated on a schedule and on staff change.
Secrets & configNo secrets in code or build artifacts; environment-specific config injected at deploy; .dev.vars-style files never leave a developer machine.
NetworkPrivate endpoints to the LLM provider and to the ERP where the platform supports them; egress allow-list so the orchestrator can reach exactly two hosts; TLS 1.2+ everywhere.
Prompt-injection & input guardrailsLayer 1 (already in the demo) blocks instruction-override patterns, enforces length and scope; extend with a classifier on the gateway and log every rejection.
Output guardrailsLayer 4 (already in the demo) validates every returned segment value against the approved value sets and strips unexpected keys; production adds a check that the resolved ledger and period are inside the user’s data-access set before the query runs.
Data minimisationPrompts contain metadata and the user’s query only. No journal amounts, no supplier or customer names, no journal descriptions. Logged prompts are classified and retained accordingly.
EncryptionIn transit (TLS) and at rest (provider-managed KMS); audit logs on immutable storage with customer-managed keys where policy requires.

10.4 · SOX, audit, and model-risk controls

A read-only window onto the general ledger does not change a financial-reporting control, but the GL is the most SOX-relevant system in the estate and this lands squarely in ITGC scope. Treat prompts, schemas, and eval sets as code — that single decision satisfies most of what an auditor will ask for.

RequirementHow it is satisfied
Complete, immutable audit trailAppend-only log of user, timestamp, raw query, parsed JSON, model and prompt version hash, ledger/period/account returned, and row count — WORM storage, retained for the evidence period (typically 7 years), exported to the SIEM.
Change managementPrompt templates, few-shot examples, approved segment value sets, and code are version-controlled; every change follows ticket → peer review → test evidence → CAB approval → deploy. A prompt edit is a code change.
Segregation of dutiesDevelopers cannot deploy to production; the integration-user owner is not a developer; production secrets are held by platform operations. Critically, this tool grants no posting ability — it cannot create, approve or post a journal.
Access recertificationQuarterly review of who can use the tool and of the integration user’s ERP roles and ledger access, evidenced and signed.
Testing evidenceA golden-query regression suite (the few-shot examples plus a larger labelled set) runs in CI before every release; pass rate and diffs are archived as release evidence.
Model risk managementAn inventory entry (intended use, limitations, owner, validation date) in the model-risk register — the SR 11-7 pattern for financial services; periodic re-validation when the model or prompt changes.
Reproducibility & lineageEvery displayed number drills to a journal line, a batch and a source; an auditor can re-run the same query in the ERP and match it.
ExplainabilityThe parsed intent JSON is the explanation and is shown to the user on every response — no hidden reasoning between the query and the data call.

10.5 · Dev → Test → Prod promotion

EnvironmentERP targetDataGate to leave
DevERP Dev instanceSynthetic or maskedUnit tests on the data layer; lint; eval suite ≥ threshold against the Test LLM deployment
Test / UATERP Test instance (post-refresh)Masked copy of productionBusiness UAT sign-off on the golden queries; security scan; performance run (p95 latency, fallback rate)
ProdERP ProductionLiveChange ticket approved; deploy in window; smoke test; hypercare with rollback ready
  • Promoted artifacts: application build, prompt templates (versioned), approved segment-value snapshot, eval set, infrastructure config (IaC) — all from the same Git tag.
  • Pipeline: branch → PR review → CI (tests + evals) → deploy to Test → UAT sign-off → CAB → deploy to Prod → smoke test.
  • Chart of accounts drift: a scheduled job refreshes segment value sets and the cross-validation rule list into Layer 2 grounding with change detection, so a new cost centre or company appears in the approved lists without a code release — and an unmapped one is surfaced rather than silently accepted.
  • Rollback: previous build and previous prompt version retained; rollback is a redeploy, and because prompts are versioned it also reverts a prompt regression.

10.6 · Operating it

  • SLOs: p95 latency, availability of the read path (the deterministic fallback keeps it alive when the LLM is down — already built), fallback rate as a quality signal, eval pass rate per release.
  • Close-window load: GL queries spike hard in the first five working days. Size for the close, not the average, and cache the chart of accounts aggressively — it barely changes.
  • Cost governance: per-user and per-team token budgets at the gateway; alert on anomalies; the unit-cost model earlier in this kit is the baseline.
  • Model lifecycle: providers retire models on a schedule — re-run the eval suite on the successor before switching, and record the switch as a change.
  • Incident runbook: LLM outage → fallback parser; ERP API outage → cached metadata with a stale banner; guardrail spike → review logs for injection attempts.

10.7 · What changes for Journals & Balancing

ConcernProduction answer
System of recordOracle ERP Cloud — journal headers, lines, batches and statuses via the ERP REST API or a BI Publisher extract; subledger accounting (SLA) owns the derivation of every automatic journal
Read/write postureRead-only. Journals are created, approved, posted and reversed in the ERP; this layer displays and explains them and can never post.
Use-case-specific controlJournal descriptions and supplier or customer names frequently contain commercially sensitive detail and occasionally personal data. Never place a journal line description in an LLM prompt; the schema here deliberately carries no transaction content at all.

10.8 · Production readiness checklist

  • ✓LLM platform selected from the governed list, DPA / zero-retention terms on file, gateway in front of it
  • ✓SSO integrated; authorisation derived from ERP roles, ledgers and data-access sets; cookie gate removed
  • ✓Read-only ERP integration user per environment, credential in a vault, rotation scheduled, no posting privilege
  • ✓Prompts, segment value sets, and eval set version-controlled and under change management
  • ✓Append-only audit log wired to the SIEM with the agreed retention
  • ✓Golden-query eval suite passing in CI; results archived as release evidence
  • ✓Dev / Test / Prod pipeline with gates, IaC, and a rehearsed rollback
  • ✓Model-risk register entry and owner named; first re-validation date set
  • ✓Chart-of-accounts refresh job scheduled with change detection for new and unmapped values
  • ✓SLOs sized for the close window, cost caps, and the incident runbook agreed with platform operations
Plan a production rollout with us → Back to the demo