The reported debit total is 125100 cents. The reported credit total is also 125100 cents. The report still fails validation.
Cash is 100 cents too high, and opening equity is 100 cents too negative. Those errors cancel in the grand total. A validator that checks only debit equals credit would approve the wrong account balances.
We will build a stronger check: reconstruct every functional USD account from complete committed journals, compare an independently supplied reported vector, and preserve the booking period and knowledge evidence behind the result. The Python and TypeScript implementations use integer arithmetic throughout.
All examples are synthetic. Ledger CB-CLOSE-2026 and entity CB-A form a separate period-close teaching branch. Values below are USD cents; its EUR evidence is validated and counted separately.
Define the report before summing it
A trial balance presents account balances in debit and credit columns. Agreement helps detect arithmetic errors, but it cannot establish that every transaction was recorded or economically classified correctly. OpenStax's trial-balance chapter and the GnuCash trial-balance report guide explain that accounting role.
Our variant is a cumulative booking-period trial balance, reconstructed from a trusted zero origin. For September 2026, include accepted journals whose explicit booking period is September or earlier and whose whole committed group is visible at knowledge cutoff K. This is a defined teaching profile; a month label alone does not imply cumulative balances, current-month expense, or any particular statutory reporting basis.
The calculation does not use an economic-time cutoff as a substitute for booking period. A September-effective adjustment deliberately booked in October belongs to the October view. The accepted command retains its prior effective date and authorized adjustment evidence.
The query chooses functional USD. EUR accounts and journals can exist in the same complete ledger export; their values never enter a USD total. Translation requires its own position, rate and accounting policy.
Keep raw signs separate from normal presentation
For an account a, accumulate debit-positive raw balance:
raw[a] = sum(debit amounts) − sum(credit amounts)
normal[a] = raw[a] for a debit-normal account
normal[a] = −raw[a] for a credit-normal account
debit column[a] = max(raw[a], 0)
credit column[a] = max(−raw[a], 0)
Assets and expenses ordinarily have debit normal balances; liabilities, equity and income ordinarily have credit normal balances. GnuCash's accounting guide describes these normal directions.
Normal side is metadata, not a command to force an account into its expected column. A cash raw balance of −50000 has a credit-column balance of 50000 and a negative normal balance. We flag that condition for review without inventing a zero floor or automatically declaring the arithmetic invalid.
A liability raw balance of −25000 has normal balance +25000 and credit balance 25000. That is an ordinary credit balance, not a negative liability in this representation.
Work the September example independently
The opening balance is an accepted balanced journal, not a free opening-balance parameter:
Opening: Dr Cash 100000
Cr Opening equity 100000
September estimate: Dr Operating accrual expense 25000
Cr Operating accrual payable 25000
October reversal: Dr Operating accrual payable 25000
Cr Operating accrual expense 25000
The estimate is effective at September's final millisecond and booked September. The exact inverse is effective at October's first midnight and booked October. Both are caught up in October. At a K after both observer receipts, the September view includes the opening and estimate, while excluding the October-booked inverse.
| USD account | Raw balance | Debit column | Credit column |
|---|---|---|---|
| Cash | 100000 | 100000 | 0 |
| Opening equity | −100000 | 0 | 100000 |
| Operating accrual expense | 25000 | 25000 | 0 |
| Operating accrual payable | −25000 | 0 | 25000 |
| Fee income | 0 | 0 | 0 |
| Interest payable | 0 | 0 | 0 |
| Other operating expense | 0 | 0 | 0 |
| Total | 0 | 125000 | 125000 |
All seven USD accounts remain in the result, including the three zeros. Our trusted chart receipt separately declares the complete chart, including two EUR accounts. An omitted unused account therefore remains detectable rather than disappearing because it has no journal legs.
Open the full-size account columns.
Now query October cumulatively. The estimate and inverse cancel in closing expense/payable balances. Closing debit and credit columns are each 100000. Historical debit movement and credit movement are each 150000: opening 100000, estimate 25000, inverse 25000. Movement volume and closing balance are different quantities.
Open the full-size period comparison.
Require independent evidence before calculation
The four-argument API keeps the query, full journal history, account catalog and trusted context explicit:
result = validate_trial_balance(query, groups, accounts, context)
const result = validateTrialBalance(query, groups, accounts, context);
The context supplies a complete source head, a zero-genesis receipt, complete chart evidence, and an independently published reported trial balance. A self-consistent JSON object does not authenticate any of these host claims. The host must obtain them from authorized sources.
The reported vector must explicitly contain every USD chart account once, including zero, and bind the queried period, K, chart version and visible source position. Its publication cannot predate the last whole committed group it claims to include.
Current account closure does not erase historical journals. Closed accounts remain reportable with their original chart status. Current posting locks are also not reapplied to accepted history.
All committed groups through the declared source head are validated before K filtering. A missing future tail remains an incomplete export even if its payload would not contribute to the queried report. Filtering first could conceal a gap.
Distinguish visible position p from validation head h
The complete export may have been observed after K. Let h be its full validation head and p the last whole-group position visible at K.
In the fixture, the estimate journal is recorded at 09:00:00.000, but its post-COMMIT observer receipt is available at 09:00:00.001. At the first instant, p is still 2 even though h is 4. At receipt equality, p becomes 3 and the September estimate enters.
A correction group contains an inverse and replacement. The members can have different recording clocks. Neither member becomes query-visible until the shared group receipt; both enter together at inclusive equality.
A post-COMMIT observer receipt is a knowledge boundary, not proof of the database's exact COMMIT timestamp. K beyond the supplied observation cannot establish later completeness.
The report binds p, not a later h. Historical queries with p less than h are useful and supported. A final current-close adapter has a stronger obligation: require the passed result's p to equal the frozen current h captured for that close generation.
Compare columns and every account
After reconstructing expected balances, normalize the independent reported raw vector using the same immutable normal-side metadata. Then calculate:
reported residual = reported debit total − reported credit total
account difference[a] = reported raw[a] − reconstructed raw[a]
The order matters:
- Malformed evidence, conflicting identities or unbalanced accepted journal structure reject.
- Missing, stale, unavailable or incomplete required evidence is indeterminate; return no partial projection.
- A complete report with nonzero reported residual fails as TRIAL_BALANCE_UNBALANCED.
- A balanced report with any account difference fails as TRIAL_BALANCE_PROJECTION_MISMATCH.
- Otherwise the result passes as TRIAL_BALANCE_VALIDATED.
Add one cent to reported cash: debit total 125001 versus credit total 125000. That fails the total check.
Instead add 100 to reported cash and subtract 100 from reported opening-equity raw balance. Both reported columns become 125100. Aggregate residual is zero, but cash difference is +100 and equity difference is −100. Both line checks fail.
Open the full-size balanced-error example.
Read the trace without overclaiming
The result retains every selected journal and its semantic leg order. Trace order is committed observer sequence, not a reconstructed intraday economic history. Each leg shows its signed raw contribution and running account balance.
All amounts and counters cross the wire as canonical digit strings. Python uses integers and TypeScript uses BigInt for money. Reported signed amounts reject minus zero, exponent notation, leading zeros and JSON numbers. The teaching implementation bounds individual journal legs and input sizes; derived sums remain exact.
The shared historical validator is pinned locally, so a copied learner package needs no sibling runtime imports. The wire contract, source-history contract and usage commands make those boundaries reviewable.
Explore the receipts and detection limits
Open the guided trial-balance lab. It begins at the complete September report. Replay receipt boundaries, switch to October, inspect closed or zero accounts, and compare a one-cent error with a balanced wrong projection.
Each receipt step uses a separately authored synthetic reported vector. The lab never generates its expected input from the kernel's calculated balances. Copied report variants are explicitly hypothetical; they do not modify accepted journals or independent evidence.
One adversarial scenario moves an expense to the wrong expense account in both the accepted books and reported vector. It passes this validator. That demonstrates the boundary: exact reconstruction proves the report agrees with supplied accepted books, while economic classification needs independent review. A fabricated complete head and matching fabricated report can likewise be internally consistent.
The independent calculations, claim ledger, primary references and raw validation receipt separate those facts from implementation choices.
Only a passed actual result should feed a close adapter. Bind its source, chart, genesis and report receipts, freeze the current head, and check the exact returned account line used by FX or intercompany work. A cumulative trial balance does not by itself establish current-month income, correct FX quantity, successful scheduled posting, or readiness to lock the period.
Continue with scheduled accrual and reversal, event-sourced balance folding, and balance proof.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
All sources accessed 2026-10-25. Synthetic Cedar data only.
GnuCash report and sign concepts
Source: GnuCash v5 Standard Reports, `10.3.5.11 and Accounting Concepts.
Version: v5 official documentation.
Accessed: 2026-10-25.
Supports: trial balance reports ending account balances; account-type debit/credit normal-side context.
Limitations: software documentation, not jurisdiction-wide banking recognition guidance, functional-currency translation or this causal export API.
OpenStax accounting text
Source: Principles of Accounting Vol.1, `3.6 Prepare a Trial Balance.
Version: official publisher web section, accessed edition.
Accessed: 2026-10-25.
Supports: ending debit/credit columns and equality failing to guarantee the absence of mistakes.
Limitations: introductory illustrative presentation; this profile deliberately retains zero accounts and adds independent projection/source checks. No published transaction table or named historical case is reproduced.
Local specification and independent evidence
Source: Data contract, independent calculations, claim ledger, 62 trial cases, shared source contract.
Version: d30-trial-balance-v1, frozen 2026-10-25.
Accessed: 2026-10-25.
Supports: authored booking-period basis, strict fields, independent trusted origins/receipts, fixed expected financial/status facts, exact arithmetic and detection limits.
Limitations: bounded synthetic reference. Full language parity supplements independent assertions and does not authenticate source evidence.
Full dependency-light reference implementations in both supported languages.
/** Auditable committed-group replay. No I/O, database, posting or sibling imports. */
type Obj=Record<string,any>;
const COMMAND=['schema_version','ledger_id','entity_id','journal_id','business_operation_id','currency','effective_at','source_available_at','booking_period','legs'];
const LEG=['account_id','side','amount_minor'];
const ACCOUNT=['account_id','ledger_id','entity_id','currency','account_type','normal_side','status'];
const EXPORTED=['command','sequence','recorded_at','adjustment_reason'];
const GROUP=['schema_version','group_id','kind','ledger_id','entity_id','available_at','journals','correction'];
const LINK=['correction_id','original_journal_id','reversal_journal_id','replacement_journal_id','reason'];
const HEAD=['stream_id','ledger_id','entity_id','observed_at','head_sequence','receipt_ref'];
const QUERY=['schema_version','ledger_id','entity_id','account_id','currency','effective_cutoff','knowledge_cutoff','checkpoint'];
const CP=['schema_version','checkpoint_id','stream_id','ledger_id','entity_id','account_id','currency','normal_side','through_sequence','effective_cutoff','raw_balance_minor','last_group_id','last_recorded_at','last_available_at','provenance_ref'];
const NORMAL:Record<string,string>={asset:'debit',expense:'debit',liability:'credit',equity:'credit',income:'credit'};
const IDENT=/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const MONEY=/^[1-9][0-9]{0,29}$/;
const SEQUENCE=/^(0|[1-9][0-9]{0,29})$/;
const SIGNED=/^(0|-?[1-9][0-9]*)$/;
const TIME=/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/;
const PERIOD=/^[0-9]{4}-(0[1-9]|1[0-2])$/;
class FoldError extends Error {}
function need(ok:any,code:string):asserts ok {if(!ok)throw new FoldError(code)}
const object=(x:any):x is Obj=>x!==null&&typeof x==='object'&&!Array.isArray(x)&&(Object.getPrototypeOf(x)===Object.prototype||Object.getPrototypeOf(x)===null);
const exact=(x:any,fields:string[]):x is Obj=>object(x)&&Object.keys(x).length===fields.length&&fields.every(k=>Object.hasOwn(x,k));
const full=(r:RegExp,s:string)=>r.exec(s)?.[0]===s;
const ident=(x:any)=>typeof x==='string'&&full(IDENT,x);
const sequence=(x:any,zero=true)=>typeof x==='string'&&full(SEQUENCE,x)&&(zero||x!=='0');
const trim=(s:string)=>s.replace(/^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g,'');
function same(a:any,b:any):boolean {
if(Array.isArray(a))return Array.isArray(b)&&a.length===b.length&&a.every((x,i)=>same(x,b[i]));
if(object(a))return object(b)&&Object.keys(a).length===Object.keys(b).length&&Object.keys(a).every(k=>Object.hasOwn(b,k)&&same(a[k],b[k]));
return a===b;
}
function timestamp(x:any):boolean {
if(typeof x!=='string'||x.length!==24||!full(TIME,x)||x.slice(0,4)==='0000')return false;
const d=new Date(x);return Number.isFinite(d.getTime())&&d.toISOString()===x;
}
function catalog(accounts:any):Map<string,Obj>{
need(Array.isArray(accounts),'INVALID_ACCOUNTS');
const result=new Map<string,Obj>();
for(const a of accounts){
need(exact(a,ACCOUNT),'INVALID_ACCOUNTS');
need(['account_id','ledger_id','entity_id'].every(k=>ident(a[k])),'INVALID_ACCOUNTS');
need(!result.has(a.account_id),'INVALID_ACCOUNTS');
need(typeof a.account_type==='string'&&Object.hasOwn(NORMAL,a.account_type),'INVALID_ACCOUNTS');
need(a.normal_side===NORMAL[a.account_type],'INVALID_ACCOUNTS');
need(typeof a.currency==='string'&&['USD','EUR'].includes(a.currency),'INVALID_ACCOUNTS');
need(typeof a.status==='string'&&['active','closed'].includes(a.status),'INVALID_ACCOUNTS');
result.set(a.account_id,a);
}
return result;
}
function validateHead(h:any){
need(exact(h,HEAD),'INVALID_CONTEXT');
need(['stream_id','ledger_id','entity_id','receipt_ref'].every(k=>ident(h[k])),'INVALID_CONTEXT');
need(timestamp(h.observed_at)&&sequence(h.head_sequence),'INVALID_CONTEXT');
}
function validateJournal(j:any,h:Obj,accounts:Map<string,Obj>):Obj{
need(exact(j,EXPORTED),'INVALID_SCHEMA');need(sequence(j.sequence,false),'INVALID_SEQUENCE');
need(timestamp(j.recorded_at),'INVALID_TIMESTAMP');need(typeof j.adjustment_reason==='string','INVALID_SCHEMA');
const c=j.command;need(exact(c,COMMAND),'INVALID_SCHEMA');
need(c.schema_version==='d30-journal-v1','UNSUPPORTED_SCHEMA');
need(['ledger_id','entity_id','journal_id','business_operation_id'].every(k=>ident(c[k])),'INVALID_IDENTIFIER');
need(c.ledger_id===h.ledger_id&&c.entity_id===h.entity_id,'SCOPE_MISMATCH');
need(typeof c.currency==='string'&&['USD','EUR'].includes(c.currency),'UNSUPPORTED_CURRENCY');
need(timestamp(c.effective_at)&×tamp(c.source_available_at),'INVALID_TIMESTAMP');
need(c.effective_at<=j.recorded_at&&c.source_available_at<=j.recorded_at,'CLOCK_ORDER_VIOLATION');
need(typeof c.booking_period==='string'&&full(PERIOD,c.booking_period)&&c.booking_period.slice(0,4)!=='0000','INVALID_SCHEMA');
need(c.booking_period!==c.effective_at.slice(0,7)?Boolean(trim(j.adjustment_reason)):j.adjustment_reason==='','INVALID_ADJUSTMENT_EVIDENCE');
need(Array.isArray(c.legs)&&c.legs.length>=2&&c.legs.length<=32,'INVALID_LEGS');
const distinct=new Set();let debit=0n,credit=0n;
for(const l of c.legs){
need(exact(l,LEG),'INVALID_SCHEMA');need(ident(l.account_id),'INVALID_IDENTIFIER');
need(typeof l.side==='string'&&['debit','credit'].includes(l.side),'INVALID_SIDE');
need(typeof l.amount_minor==='string'&&full(MONEY,l.amount_minor),'INVALID_AMOUNT');
const a=accounts.get(l.account_id);need(a!==undefined,'UNKNOWN_ACCOUNT');
need(a.ledger_id===h.ledger_id&&a.entity_id===h.entity_id,'SCOPE_MISMATCH');need(a.currency===c.currency,'CURRENCY_MISMATCH');
distinct.add(l.account_id);if(l.side==='debit')debit+=BigInt(l.amount_minor);else credit+=BigInt(l.amount_minor);
}
need(distinct.size>=2,'DISTINCT_ACCOUNTS_REQUIRED');need(debit===credit,'UNBALANCED_JOURNAL');return c;
}
function validateHistory(groups:any,h:Obj,accounts:Map<string,Obj>){
need(Array.isArray(groups)&&groups.length<=4096,'INVALID_GROUP');
const knownGroups=new Map<string,Obj>(),journals=new Map<string,Obj>(),business=new Set<string>(),corrections=new Set<string>(),reversedIds=new Set<string>(),reversalIds=new Set<string>();
let expected=1n,lastRecorded:string|null=null,lastAvailable:string|null=null;
for(const g of groups){
need(exact(g,GROUP),'INVALID_GROUP');need(ident(g.group_id),'INVALID_IDENTIFIER');
if(knownGroups.has(g.group_id)){need(!same(knownGroups.get(g.group_id),g),'DUPLICATE_GROUP');throw new FoldError('GROUP_ID_CONFLICT')}
knownGroups.set(g.group_id,g);need(g.schema_version==='d30-committed-group-v1','UNSUPPORTED_SCHEMA');
need(g.ledger_id===h.ledger_id&&g.entity_id===h.entity_id,'SCOPE_MISMATCH');
need(typeof g.kind==='string'&&['posting','correction'].includes(g.kind),'INVALID_GROUP');
need(timestamp(g.available_at),'INVALID_TIMESTAMP');
need(g.available_at<=h.observed_at&&(lastAvailable===null||g.available_at>=lastAvailable),'CLOCK_ORDER_VIOLATION');
need(Array.isArray(g.journals)&&g.journals.length===(g.kind==='posting'?1:2),'INVALID_GROUP');
if(g.kind==='posting')need(g.correction===null,'INVALID_CORRECTION_LINK');
const local:Obj[]=[];
for(const j of g.journals){
const c=validateJournal(j,h,accounts),n=BigInt(j.sequence);
need(n>=expected,'OUT_OF_ORDER_GROUP');need(n<=expected,'SEQUENCE_GAP');
need(j.recorded_at<=g.available_at&&(lastRecorded===null||j.recorded_at>=lastRecorded),'CLOCK_ORDER_VIOLATION');
need(!journals.has(c.journal_id)&&local.every(x=>x.command.journal_id!==c.journal_id),'DUPLICATE_JOURNAL');
need(!business.has(c.business_operation_id)&&local.every(x=>x.command.business_operation_id!==c.business_operation_id),'DUPLICATE_BUSINESS_OPERATION');
local.push(j);lastRecorded=j.recorded_at;expected++;
}
if(g.kind==='correction'){
const l=g.correction;need(exact(l,LINK),'INVALID_CORRECTION_LINK');
need(['correction_id','original_journal_id','reversal_journal_id','replacement_journal_id'].every(k=>ident(l[k])),'INVALID_CORRECTION_LINK');
need(typeof l.reason==='string'&&[...l.reason].length>=1&&[...l.reason].length<=240&&l.reason===trim(l.reason)&&[...l.reason].every(c=>c.codePointAt(0)!<0xd800||c.codePointAt(0)!>0xdfff),'INVALID_CORRECTION_LINK');
need(!corrections.has(l.correction_id),'INVALID_CORRECTION_LINK');
need(journals.has(l.original_journal_id),'ORIGINAL_NOT_FOUND');need(!reversedIds.has(l.original_journal_id),'ALREADY_REVERSED');need(!reversalIds.has(l.original_journal_id),'REVERSAL_OF_REVERSAL');
const [r,p]=local.map(j=>j.command);
need(l.reversal_journal_id===r.journal_id&&l.replacement_journal_id===p.journal_id,'INVALID_CORRECTION_LINK');
need(['ledger_id','entity_id','currency','effective_at','source_available_at','booking_period'].every(k=>r[k]===p[k]),'INVALID_CORRECTION_LINK');
const original=journals.get(l.original_journal_id)!.command;need(original.currency===r.currency,'CURRENCY_MISMATCH');
const inverse=original.legs.map((leg:Obj)=>({...leg,side:leg.side==='debit'?'credit':'debit'}));
need(same(r.legs,inverse),'INVALID_REVERSAL');corrections.add(l.correction_id);reversedIds.add(l.original_journal_id);reversalIds.add(r.journal_id);
}
for(const j of local){journals.set(j.command.journal_id,j);business.add(j.command.business_operation_id)}
lastAvailable=g.available_at;
}
need(expected-1n<=BigInt(h.head_sequence),'HEAD_SEQUENCE_MISMATCH');need(expected-1n>=BigInt(h.head_sequence),'INCOMPLETE_EXPORT');
}
function calculate(groups:Obj[],account:Obj,effective:string,knowledge:string,initialRaw=0n):[bigint,Obj[],Obj[]]{
let raw=initialRaw;const trace:Obj[]=[],visible:Obj[]=[];
for(const g of groups){
if(g.available_at>knowledge)continue;let delta=0n;const selected:string[]=[];
for(const j of g.journals){const c=j.command;if(c.effective_at<=effective){selected.push(c.journal_id);for(const l of c.legs)if(l.account_id===account.account_id)delta+=BigInt(l.amount_minor)*(l.side==='debit'?1n:-1n)}}
raw+=delta;visible.push(g);trace.push({group_id:g.group_id,first_sequence:g.journals[0].sequence,last_sequence:g.journals.at(-1).sequence,available_at:g.available_at,selected_journal_ids:selected,raw_delta_minor:delta.toString(),raw_balance_minor:raw.toString(),normal_balance_minor:(account.normal_side==='debit'?raw:-raw).toString()});
}
return [raw,trace,visible];
}
function validateCheckpoint(cp:any){
need(exact(cp,CP),'CHECKPOINT_MISMATCH');need(cp.schema_version==='d30-balance-checkpoint-v1','CHECKPOINT_MISMATCH');
need(['checkpoint_id','stream_id','ledger_id','entity_id','account_id','provenance_ref'].every(k=>ident(cp[k])),'CHECKPOINT_MISMATCH');
need(['USD','EUR'].includes(cp.currency)&&['debit','credit'].includes(cp.normal_side),'CHECKPOINT_MISMATCH');
need(sequence(cp.through_sequence)&×tamp(cp.effective_cutoff),'CHECKPOINT_MISMATCH');
need(typeof cp.raw_balance_minor==='string'&&cp.raw_balance_minor.length<=128&&full(SIGNED,cp.raw_balance_minor),'CHECKPOINT_MISMATCH');
if(cp.through_sequence==='0')need(cp.last_group_id===null&&cp.last_recorded_at===null&&cp.last_available_at===null&&cp.raw_balance_minor==='0','CHECKPOINT_MISMATCH');
else need(ident(cp.last_group_id)&×tamp(cp.last_recorded_at)&×tamp(cp.last_available_at),'CHECKPOINT_MISMATCH');
}
export function foldBalance(query:any,groups:any,accounts:any,context:any):any{
const failed={status:'rejected',code:'',balance:null,coverage:null,trace:[],checkpoint:null,checkpoint_registry_entry:null};
try{
const cats=catalog(accounts);need(exact(context,['head','checkpoint_registry']),'INVALID_CONTEXT');const h=context.head;validateHead(h);
need(Array.isArray(context.checkpoint_registry),'INVALID_CONTEXT');
for(const entry of context.checkpoint_registry){need(exact(entry,['checkpoint','prefix_groups']),'INVALID_CONTEXT');validateCheckpoint(entry.checkpoint);need(Array.isArray(entry.prefix_groups),'INVALID_CONTEXT');need(entry.prefix_groups.length<=4096,'INVALID_GROUP')}
need(exact(query,QUERY),'INVALID_SCHEMA');need(query.schema_version==='d30-balance-fold-v1','UNSUPPORTED_SCHEMA');
need(['ledger_id','entity_id','account_id'].every(k=>ident(query[k])),'INVALID_IDENTIFIER');need(query.ledger_id===h.ledger_id&&query.entity_id===h.entity_id,'SCOPE_MISMATCH');
need(cats.has(query.account_id),'UNKNOWN_ACCOUNT');const account=cats.get(query.account_id)!;
need(account.ledger_id===h.ledger_id&&account.entity_id===h.entity_id,'SCOPE_MISMATCH');need(typeof query.currency==='string'&&['USD','EUR'].includes(query.currency),'UNSUPPORTED_CURRENCY');need(query.currency===account.currency,'CURRENCY_MISMATCH');
need(timestamp(query.effective_cutoff)&×tamp(query.knowledge_cutoff),'INVALID_TIMESTAMP');need(query.knowledge_cutoff<=h.observed_at,'KNOWLEDGE_BEYOND_EXPORT');need(Array.isArray(groups),'INVALID_GROUP');
const cp=query.checkpoint;let prefix:Obj[]=[],prefixTrace:Obj[]=[];
if(cp!==null){
validateCheckpoint(cp);
need(['stream_id','ledger_id','entity_id'].every(k=>cp[k]===h[k])&&cp.account_id===query.account_id&&cp.currency===query.currency&&cp.normal_side===account.normal_side,'CHECKPOINT_MISMATCH');
need(cp.effective_cutoff===query.effective_cutoff,'CHECKPOINT_QUERY_MISMATCH');
const entries:Obj[]=[];for(const entry of context.checkpoint_registry){need(exact(entry,['checkpoint','prefix_groups']),'INVALID_CONTEXT');need(object(entry.checkpoint),'INVALID_CONTEXT');if(entry.checkpoint.checkpoint_id===cp.checkpoint_id)entries.push(entry)}
need(entries.length>0,'UNKNOWN_CHECKPOINT');need(entries.length===1,'INVALID_CONTEXT');need(same(entries[0].checkpoint,cp),'CHECKPOINT_MISMATCH');
need(cp.last_available_at===null||cp.last_available_at<=query.knowledge_cutoff,'CHECKPOINT_AFTER_KNOWLEDGE');
prefix=entries[0].prefix_groups;validateHistory(prefix,{...h,head_sequence:cp.through_sequence},cats);
const last=prefix.at(-1);need(cp.last_group_id===(last?.group_id??null)&&cp.last_recorded_at===(last?.journals.at(-1).recorded_at??null)&&cp.last_available_at===(last?.available_at??null),'CHECKPOINT_MISMATCH');
const prefixResult=calculate(prefix,account,cp.effective_cutoff,h.observed_at);prefixTrace=prefixResult[1];
need(prefixResult[0].toString()===cp.raw_balance_minor,'CHECKPOINT_BALANCE_MISMATCH');
if(groups.length){const first=groups[0];need(object(first)&&Array.isArray(first.journals)&&first.journals.length>0,'INVALID_GROUP');const seq=object(first.journals[0])?first.journals[0].sequence:null;need(sequence(seq,false),'INVALID_SEQUENCE');need(BigInt(seq)>BigInt(cp.through_sequence),'CHECKPOINT_OVERLAP')}
}
const allGroups=[...prefix,...groups];validateHistory(allGroups,h,cats);
const replay=cp===null?calculate(groups,account,query.effective_cutoff,query.knowledge_cutoff):calculate(groups,account,query.effective_cutoff,query.knowledge_cutoff,BigInt(cp.raw_balance_minor));
const raw=replay[0],trace=cp===null?replay[1]:[...prefixTrace,...replay[1]],visible=cp===null?replay[2]:[...prefix,...replay[2]],last=visible.at(-1),through=last?.journals.at(-1).sequence??'0';
const checkpoint={schema_version:'d30-balance-checkpoint-v1',checkpoint_id:'CP-'+through+'-'+query.effective_cutoff.replace(/[^0-9]/g,''),stream_id:h.stream_id,ledger_id:h.ledger_id,entity_id:h.entity_id,account_id:query.account_id,currency:query.currency,normal_side:account.normal_side,through_sequence:through,effective_cutoff:query.effective_cutoff,raw_balance_minor:raw.toString(),last_group_id:last?.group_id??null,last_recorded_at:last?.journals.at(-1).recorded_at??null,last_available_at:last?.available_at??null,provenance_ref:h.receipt_ref};
return {status:'ok',code:'BALANCE_FOLDED',balance:{account_id:query.account_id,currency:query.currency,normal_side:account.normal_side,raw_balance_minor:raw.toString(),normal_balance_minor:(account.normal_side==='debit'?raw:-raw).toString()},coverage:{stream_id:h.stream_id,receipt_ref:h.receipt_ref,head_sequence:h.head_sequence,validated_through_sequence:h.head_sequence,visible_through_sequence:through,checkpoint_id:cp?.checkpoint_id??null,observed_at:h.observed_at},trace,checkpoint,checkpoint_registry_entry:{checkpoint:structuredClone(checkpoint),prefix_groups:structuredClone(visible)}};
}catch(error){if(error instanceof FoldError)return {...failed,code:error.message};throw error}
}
// Trial extension. The preceding Stage 2 core is pinned unchanged.
const TQUERY=['schema_version','ledger_id','entity_id','period','currency','basis','knowledge_cutoff'];
const TCONTEXT=['head','genesis','chart_evidence','reported_trial_balance'];
const GENESIS=['schema_version','stream_id','ledger_id','entity_id','opening_sequence','opening_basis','available_at','receipt_ref'];
const CHART_EVIDENCE=['schema_version','chart_id','chart_version_ref','ledger_id','entity_id','available_at','observed_at','account_count','account_ids','receipt_ref'];
const REPORTED=['schema_version','evidence_id','ledger_id','entity_id','period','currency','basis','knowledge_cutoff','observed_at','head_sequence','receipt_ref','stream_id','balance_basis','chart_id','chart_version_ref','available_at','balances'];
const REPORTED_SIGNED=/^(0|-?[1-9][0-9]{0,119})$/;
const TRIAL_SOURCE_GAPS=new Set(['SEQUENCE_GAP','INCOMPLETE_EXPORT']);
const tcompare=(a:string,b:string)=>a<b?-1:a>b?1:0;
const trialPeriod=(v:any)=>typeof v==='string'&&full(PERIOD,v)&&v.slice(0,4)!=='0000';
function measurement(v:string):string{
const [year,month]=v.split('-').map(Number),leap=year%4===0&&(year%100!==0||year%400===0);
const days=[31,leap?29:28,31,30,31,30,31,31,30,31,30,31][month-1];
return v+'-'+String(days).padStart(2,'0')+'T23:59:59.999Z';
}
function trialFailed(status:string,code:string):Obj{return{status,code,scope:null,source_evidence:null,chart_evidence:null,reported_receipt:null,lines:[],totals:null,trace:[]}}
function trialHeaders(query:any,context:any,accounts:any):[string,Map<string,Obj>]{
need(exact(query,TQUERY),'INVALID_SCHEMA');need(query.schema_version==='d30-trial-balance-v1','UNSUPPORTED_SCHEMA');
need(['ledger_id','entity_id'].every(k=>ident(query[k])),'INVALID_IDENTIFIER');need(trialPeriod(query.period),'INVALID_PERIOD');
need(query.currency==='USD','UNSUPPORTED_CURRENCY');need(query.basis==='functional-usd','UNSUPPORTED_REPORTING_BASIS');
need(timestamp(query.knowledge_cutoff),'INVALID_TIMESTAMP');const at=measurement(query.period);
need(query.knowledge_cutoff>=at,'KNOWLEDGE_BEFORE_MEASUREMENT');need(exact(context,TCONTEXT),'INVALID_CONTEXT');
validateHead(context.head);need(['ledger_id','entity_id'].every(k=>context.head[k]===query[k]),'SCOPE_MISMATCH');
need(Array.isArray(accounts)&&accounts.length<=256,'INVALID_ACCOUNTS');const cats=catalog(accounts);
need(accounts.every(a=>['ledger_id','entity_id'].every(k=>a[k]===query[k])),'SCOPE_MISMATCH');
return[at,cats];
}
function trialGenesis(query:Obj,head:Obj,genesis:any):string[]{
if(genesis===null)return['MISSING_GENESIS'];need(exact(genesis,GENESIS),'INVALID_GENESIS');
need(genesis.schema_version==='d30-ledger-genesis-v1','INVALID_GENESIS');
need(['stream_id','ledger_id','entity_id','receipt_ref'].every(k=>ident(genesis[k])),'INVALID_GENESIS');
need(['stream_id','ledger_id','entity_id'].every(k=>genesis[k]===head[k]),'SCOPE_MISMATCH');
need(genesis.opening_sequence==='0'&&genesis.opening_basis==='all-chart-balances-zero','UNSUPPORTED_GENESIS');
need(timestamp(genesis.available_at),'INVALID_GENESIS');return genesis.available_at>query.knowledge_cutoff?['GENESIS_NOT_AVAILABLE']:[];
}
function trialChart(query:Obj,cats:Map<string,Obj>,chart:any):string[]{
if(chart===null)return['MISSING_CHART_EVIDENCE'];need(exact(chart,CHART_EVIDENCE),'INVALID_CHART_EVIDENCE');
need(chart.schema_version==='d30-chart-evidence-v1','INVALID_CHART_EVIDENCE');
need(['chart_id','chart_version_ref','ledger_id','entity_id','receipt_ref'].every(k=>ident(chart[k])),'INVALID_CHART_EVIDENCE');
need(['ledger_id','entity_id'].every(k=>chart[k]===query[k]),'SCOPE_MISMATCH');
need(timestamp(chart.available_at)&×tamp(chart.observed_at)&&chart.available_at<=chart.observed_at,'INVALID_CHART_EVIDENCE');
need(sequence(chart.account_count)&&BigInt(chart.account_count)<=256n,'INVALID_CHART_EVIDENCE');
const ids=chart.account_ids;need(Array.isArray(ids)&&BigInt(ids.length)===BigInt(chart.account_count)&&ids.every(ident)&&new Set(ids).size===ids.length,'INVALID_CHART_EVIDENCE');
const actual=new Set(cats.keys()),declared=new Set<string>(ids);need(![...actual].some(x=>!declared.has(x)),'CHART_ACCOUNT_SET_MISMATCH');
const gaps:string[]=[];if([...declared].some(x=>!actual.has(x)))gaps.push('INCOMPLETE_CHART');
if(chart.available_at>query.knowledge_cutoff)gaps.push('CHART_NOT_AVAILABLE');if(chart.observed_at<query.knowledge_cutoff)gaps.push('KNOWLEDGE_BEYOND_CHART');return gaps;
}
function trialReport(query:Obj,head:Obj,cats:Map<string,Obj>,chart:any,reported:any,chartComplete:boolean):string[]{
if(reported===null)return['MISSING_REPORTED_TRIAL_BALANCE'];need(exact(reported,REPORTED),'INVALID_REPORTED_SCHEMA');
need(reported.schema_version==='d30-close-source-v1','INVALID_REPORTED_SCHEMA');
need(['evidence_id','ledger_id','entity_id','receipt_ref','stream_id','chart_id','chart_version_ref'].every(k=>ident(reported[k])),'INVALID_REPORTED_SCHEMA');
need(['ledger_id','entity_id','currency'].every(k=>reported[k]===query[k])&&reported.stream_id===head.stream_id,'SCOPE_MISMATCH');
need(reported.basis==='functional-usd'&&reported.balance_basis==='cumulative-booking-period-v1','UNSUPPORTED_REPORTING_BASIS');
need(trialPeriod(reported.period),'INVALID_REPORTED_SCHEMA');need(['knowledge_cutoff','observed_at','available_at'].every(k=>timestamp(reported[k])),'INVALID_REPORTED_SCHEMA');
need(measurement(reported.period)<=reported.available_at&&reported.available_at<=reported.observed_at,'INVALID_REPORTED_CLOCK');
need(sequence(reported.head_sequence),'INVALID_REPORTED_SCHEMA');const rows=reported.balances;need(Array.isArray(rows)&&rows.length<=256,'INVALID_REPORTED_SCHEMA');
const errors:string[]=[];for(const row of rows){try{need(exact(row,['account_id','raw_balance_minor'])&&ident(row.account_id),'INVALID_REPORTED_SCHEMA');need(typeof row.raw_balance_minor==='string'&&full(REPORTED_SIGNED,row.raw_balance_minor),'INVALID_REPORTED_AMOUNT')}catch(error){if(error instanceof FoldError)errors.push(error.message);else throw error}}
if(errors.length)throw new FoldError(errors.sort(tcompare)[0]);
const seen=new Map<string,Obj[]>();for(const row of rows)seen.set(row.account_id,[...(seen.get(row.account_id)??[]),row]);
need(![...seen.values()].some(items=>items.some(row=>!same(row,items[0]))),'REPORTED_ACCOUNT_CONFLICT');need(![...seen.values()].some(items=>items.length>1),'DUPLICATE_REPORTED_ACCOUNT');
const gaps:string[]=[];
if(chartComplete){const expected=new Set([...cats].filter(([,a])=>a.currency==='USD').map(([id])=>id)),actual=new Set(seen.keys());need(![...actual].some(x=>!expected.has(x)),'REPORTED_ACCOUNT_SET_MISMATCH');if([...expected].some(x=>!actual.has(x)))gaps.push('INCOMPLETE_REPORTED_BALANCES')}
if(reported.period!==query.period||reported.knowledge_cutoff!==query.knowledge_cutoff)gaps.push('REPORTED_QUERY_MISMATCH');
if(chart!==null&&['chart_id','chart_version_ref'].some(k=>reported[k]!==chart[k]))gaps.push('REPORTED_CHART_MISMATCH');
if(reported.available_at>query.knowledge_cutoff)gaps.push('REPORTED_NOT_AVAILABLE');if(reported.observed_at<query.knowledge_cutoff)gaps.push('KNOWLEDGE_BEYOND_REPORTED_SOURCE');return gaps;
}
export function validateTrialBalance(query:any,groups:any,accounts:any,context:any):Obj{
try{
const[at,cats]=trialHeaders(query,context,accounts),head=context.head,errors:string[]=[],gaps:string[]=[];
try{validateHistory(groups,head,cats)}catch(error){if(error instanceof FoldError){if(TRIAL_SOURCE_GAPS.has(error.message))gaps.push(error.message);else errors.push(error.message)}else throw error}
if(query.knowledge_cutoff>head.observed_at)gaps.push('KNOWLEDGE_BEYOND_EXPORT');
try{gaps.push(...trialGenesis(query,head,context.genesis))}catch(error){if(error instanceof FoldError)errors.push(error.message);else throw error}
const chart=context.chart_evidence;let chartGaps:string[]=[],chartValid=false;
try{chartGaps=trialChart(query,cats,chart);gaps.push(...chartGaps);chartValid=chart!==null}catch(error){if(error instanceof FoldError)errors.push(error.message);else throw error}
const reported=context.reported_trial_balance;
try{gaps.push(...trialReport(query,head,cats,chartValid?chart:null,reported,chartValid&&!chartGaps.length&&!errors.length))}catch(error){if(error instanceof FoldError)errors.push(error.message);else throw error}
if(errors.length)throw new FoldError(errors.sort(tcompare)[0]);if(gaps.length)return trialFailed('indeterminate',gaps[0]);
const visible:Obj[]=groups.filter((g:Obj)=>g.available_at<=query.knowledge_cutoff),position=visible.length?visible.at(-1)!.journals.at(-1).sequence:'0';
if(reported.head_sequence!==position)return trialFailed('indeterminate','REPORTED_HEAD_MISMATCH');
if(visible.length&&reported.available_at<visible.at(-1)!.available_at)return trialFailed('indeterminate','STALE_REPORTED_RECEIPT');
const chosen=new Map([...cats].filter(([,a])=>a.currency==='USD')),raw=new Map<string,bigint>(),debits=new Map<string,bigint>(),credits=new Map<string,bigint>();
for(const key of chosen.keys()){raw.set(key,0n);debits.set(key,0n);credits.set(key,0n)}
const trace:Obj[]=[];let foreign=0,later=0;
for(const group of visible)for(const journal of group.journals){
const cmd=journal.command;if(cmd.booking_period>query.period){later++;continue}if(cmd.currency!=='USD'){foreign++;continue}
const legs:Obj[]=[];let debit=0n,credit=0n;
for(const leg of cmd.legs){
const account=leg.account_id,amount=BigInt(leg.amount_minor),delta=leg.side==='debit'?amount:-amount;raw.set(account,raw.get(account)!+delta);
if(delta>0n){debits.set(account,debits.get(account)!+amount);debit+=amount}else{credits.set(account,credits.get(account)!+amount);credit+=amount}
legs.push({...structuredClone(leg),raw_delta_minor:delta.toString(),raw_balance_after_minor:raw.get(account)!.toString()});
}
trace.push({group_id:group.group_id,available_at:group.available_at,journal_id:cmd.journal_id,business_operation_id:cmd.business_operation_id,sequence:journal.sequence,recorded_at:journal.recorded_at,effective_at:cmd.effective_at,booking_period:cmd.booking_period,currency:cmd.currency,debit_minor:debit.toString(),credit_minor:credit.toString(),legs});
}
const reportMap=new Map<string,bigint>(reported.balances.map((r:Obj)=>[r.account_id,BigInt(r.raw_balance_minor)])),lines:Obj[]=[];
const positive=(x:bigint)=>x>0n?x:0n;
for(const[key,a]of[...chosen].sort(([a],[b])=>tcompare(a,b))){
const expected=raw.get(key)!,actual=reportMap.get(key)!,normal=a.normal_side==='debit'?expected:-expected,reportedNormal=a.normal_side==='debit'?actual:-actual;
lines.push({account_id:a.account_id,currency:a.currency,account_type:a.account_type,normal_side:a.normal_side,status:a.status,debit_movement_minor:debits.get(key)!.toString(),credit_movement_minor:credits.get(key)!.toString(),raw_balance_minor:expected.toString(),normal_balance_minor:normal.toString(),debit_balance_minor:positive(expected).toString(),credit_balance_minor:positive(-expected).toString(),reported_raw_balance_minor:actual.toString(),reported_normal_balance_minor:reportedNormal.toString(),reported_debit_balance_minor:positive(actual).toString(),reported_credit_balance_minor:positive(-actual).toString(),difference_minor:(actual-expected).toString(),abnormal_balance:normal<0n,matches_reported:actual===expected});
}
const total=(field:string)=>lines.reduce((n,r)=>n+BigInt(r[field]),0n);
const moveDebit=total('debit_movement_minor'),moveCredit=total('credit_movement_minor'),debit=total('debit_balance_minor'),credit=total('credit_balance_minor'),reportedDebit=total('reported_debit_balance_minor'),reportedCredit=total('reported_credit_balance_minor'),mismatches=lines.filter(r=>!r.matches_reported).length;
const totals={debit_movement_minor:moveDebit.toString(),credit_movement_minor:moveCredit.toString(),raw_movement_residual_minor:(moveDebit-moveCredit).toString(),debit_balance_minor:debit.toString(),credit_balance_minor:credit.toString(),residual_minor:(debit-credit).toString(),reported_debit_balance_minor:reportedDebit.toString(),reported_credit_balance_minor:reportedCredit.toString(),reported_residual_minor:(reportedDebit-reportedCredit).toString(),mismatched_account_count:String(mismatches),account_count:String(lines.length),zero_account_count:String(lines.filter(r=>r.raw_balance_minor==='0').length),abnormal_account_count:String(lines.filter(r=>r.abnormal_balance).length)};
const status=reportedDebit!==reportedCredit||mismatches?'fail':'pass',code=reportedDebit!==reportedCredit?'TRIAL_BALANCE_UNBALANCED':mismatches?'TRIAL_BALANCE_PROJECTION_MISMATCH':'TRIAL_BALANCE_VALIDATED';
const scope={ledger_id:query.ledger_id,entity_id:query.entity_id,period:query.period,currency:query.currency,basis:query.basis,knowledge_cutoff:query.knowledge_cutoff,balance_basis:'cumulative-booking-period-v1',measurement_at:at};
const coverage={stream_id:head.stream_id,receipt_ref:head.receipt_ref,observed_at:head.observed_at,head_sequence:head.head_sequence,validated_through_sequence:head.head_sequence,visible_through_sequence:position,last_visible_group_id:visible.length?visible.at(-1)!.group_id:null,last_visible_available_at:visible.length?visible.at(-1)!.available_at:null,genesis_receipt_ref:context.genesis.receipt_ref,excluded_currency_account_count:String(cats.size-chosen.size),excluded_currency_journal_count:String(foreign),excluded_later_booking_journal_count:String(later),selected_journal_count:String(trace.length)};
const chartCopy=structuredClone(chart);chartCopy.account_ids.sort(tcompare);const reportCopy=structuredClone(reported);reportCopy.balances.sort((a:Obj,b:Obj)=>tcompare(a.account_id,b.account_id));
return{status,code,scope,source_evidence:coverage,chart_evidence:chartCopy,reported_receipt:reportCopy,lines,totals,trace};
}catch(error){if(error instanceof FoldError)return trialFailed('rejected',error.message);throw error}
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
