nx_balance.nx source
↩ module page · 261 lines · 10496 B
1// nx_balance.nx -- per-account balance cache for the ledger.
2//
3// module: nishi-core.finance.balance
4// depends: nishi-core.finance.ledger, nishi-core.io.iso8601
5// disk_kb: 5
6// capability: MARKETPLACE
7//
8// license_tier: PUBLIC_NISHI_SUBSTRATE
9// genealogy_id: standard_accounting_running_balance_pattern +
10// cardinal_13_additive_only +
11// nishi_pillar_4_bits_up_sovereign_2026
12//
13// Per-account running-balance cache. The ledger (`nx_ledger.nx`)
14// is the authoritative event log; this primitive maintains O(1)
15// balance lookups by listening to each posted transaction and
16// updating per-account totals.
17//
18// Per Cardinal 13 (additive-only): cache entries are append-only
19// snapshots. Each transaction post creates a new BalanceSnapshot
20// row stamped with the post_ts. Querying current balance returns
21// the latest is_current=1 snapshot; querying historical balance
22// at time T returns the latest snapshot with post_ts <= T.
23//
24// ===== Why a cache when we have the ledger? =======================
25//
26// Walking the ledger for every balance query is O(N) in
27// transaction count. At 100k+ transactions/year, that's prohibitive
28// for UI dashboards + dunning + fraud-detection workflows. The
29// cache is a denormalized projection that maintains the invariant:
30//
31// sum(line.amount where account_id = A and is_debit = 1)
32// - sum(line.amount where account_id = A and is_debit = 0)
33// == BalanceSnapshot.balance_minor_q10 for A's latest snapshot
34//
35// Substrate periodically asserts this invariant via
36// nx_balance_reconcile_against_ledger (queued).
37
38// nx_safety_envelope:
39// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
40// sil_target: SIL1
41// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
42// verdict: NOT_YET_EVALUATED
43
44import "nx_syscalls.nx"
45import "nx_ledger.nx"
46
47// ===== BalanceVerdict sealed enum =================================
48
49const NX_BALANCE_POSITIVE: i64 = 1
50const NX_BALANCE_ZERO: i64 = 2
51const NX_BALANCE_NEGATIVE: i64 = 3 // overdraft; substrate-level alert
52const NX_BALANCE_UNCONFIRMED: i64 = 4 // pending transaction not posted
53const NX_BALANCE_RECONCILING: i64 = 5 // mid-ledger-walk; do not trust
54const NX_BALANCE_ACCOUNT_NOT_FOUND: i64 = 6
55
56func nx_balance_verdict_name(v: i64) -> *u8 {
57 if v == NX_BALANCE_POSITIVE { return "POSITIVE" }
58 if v == NX_BALANCE_ZERO { return "ZERO" }
59 if v == NX_BALANCE_NEGATIVE { return "NEGATIVE" }
60 if v == NX_BALANCE_UNCONFIRMED { return "UNCONFIRMED" }
61 if v == NX_BALANCE_RECONCILING { return "RECONCILING" }
62 if v == NX_BALANCE_ACCOUNT_NOT_FOUND { return "ACCOUNT_NOT_FOUND" }
63 return "UNKNOWN"
64}
65
66// ===== BalanceSnapshot struct =====================================
67//
68// One row per balance-change event. Cardinal 13 additive-only:
69// snapshots are never deleted or mutated. is_current=1 marks the
70// latest snapshot for each account_id (only one such row per
71// account at any time).
72
73struct BalanceSnapshot {
74 snapshot_hk: i64,
75 account_id: i64,
76 // Balance at this snapshot (Q10 currency-native minor units)
77 balance_minor_q10: i64,
78 // Triggering transaction
79 triggering_tx_hk: i64, // FK to LedgerTransaction
80 triggering_line_count: i64, // lines in that tx affecting this account
81 // Cumulative counters (useful for UI summaries)
82 cumulative_debits_minor_q10: i64,
83 cumulative_credits_minor_q10: i64,
84 n_transactions_to_date: i64,
85 // Verdict at this snapshot
86 verdict: i64, // NX_BALANCE_*
87 // Lifecycle
88 post_ts_unix: i64,
89 is_current: i64,
90 superseded_by_hk: i64,
91}
92
93const NX_BALANCE_SNAPSHOT_BYTES: i64 = 96 // 12 fields * 8 bytes
94
95// ===== Verdict resolver ===========================================
96
97func nx_balance_resolve_verdict(balance_minor_q10: i64) -> i64 {
98 if balance_minor_q10 > 0 { return NX_BALANCE_POSITIVE }
99 if balance_minor_q10 == 0 { return NX_BALANCE_ZERO }
100 return NX_BALANCE_NEGATIVE
101}
102
103// ===== Apply a transaction line ===================================
104//
105// Called by ledger-post hook after a transaction commits. Each
106// affected line bumps cumulative-debit-or-credit + creates a new
107// is_current=1 snapshot per affected account. Previous is_current
108// snapshot for that account gets is_current=0 + superseded_by_hk
109// pointer.
110
111func nx_balance_apply_line(
112 prev_snapshot: *BalanceSnapshot,
113 line_amount_q10: i64,
114 is_debit: i64,
115 triggering_tx_hk: i64,
116 post_ts_unix: i64,
117 is_debit_normal: i64
118) -> *BalanceSnapshot {
119 let raw: *u8 = sys_mmap(NX_BALANCE_SNAPSHOT_BYTES)
120 let s: *BalanceSnapshot = raw as *BalanceSnapshot
121 s.snapshot_hk = 0
122 s.account_id = if prev_snapshot == 0 as *BalanceSnapshot { 0 } else { prev_snapshot.account_id }
123 s.triggering_tx_hk = triggering_tx_hk
124 s.triggering_line_count = 1
125 s.post_ts_unix = post_ts_unix
126 s.is_current = 1
127 s.superseded_by_hk = 0
128
129 // Carry forward cumulative + balance
130 var prev_balance: i64 = 0
131 var prev_cum_d: i64 = 0
132 var prev_cum_c: i64 = 0
133 var prev_n_tx: i64 = 0
134 if prev_snapshot != 0 as *BalanceSnapshot {
135 prev_balance = prev_snapshot.balance_minor_q10
136 prev_cum_d = prev_snapshot.cumulative_debits_minor_q10
137 prev_cum_c = prev_snapshot.cumulative_credits_minor_q10
138 prev_n_tx = prev_snapshot.n_transactions_to_date
139 }
140
141 // Balance delta depends on account's normal-balance direction:
142 // debit-normal account (ASSET/EXPENSE):
143 // debit → balance increases
144 // credit → balance decreases
145 // credit-normal account (LIABILITY/EQUITY/REVENUE):
146 // debit → balance decreases
147 // credit → balance increases
148 var delta: i64 = 0
149 if is_debit == 1 {
150 s.cumulative_debits_minor_q10 = prev_cum_d + line_amount_q10
151 s.cumulative_credits_minor_q10 = prev_cum_c
152 if is_debit_normal == 1 { delta = line_amount_q10 }
153 if is_debit_normal == 0 { delta = -line_amount_q10 }
154 }
155 if is_debit == 0 {
156 s.cumulative_debits_minor_q10 = prev_cum_d
157 s.cumulative_credits_minor_q10 = prev_cum_c + line_amount_q10
158 if is_debit_normal == 1 { delta = -line_amount_q10 }
159 if is_debit_normal == 0 { delta = line_amount_q10 }
160 }
161 s.balance_minor_q10 = prev_balance + delta
162 s.n_transactions_to_date = prev_n_tx + 1
163 s.verdict = nx_balance_resolve_verdict(s.balance_minor_q10)
164
165 // Mark previous snapshot superseded (Cardinal 13)
166 if prev_snapshot != 0 as *BalanceSnapshot {
167 prev_snapshot.is_current = 0
168 prev_snapshot.superseded_by_hk = s.snapshot_hk
169 }
170 return s
171}
172
173// ===== O(1) current-balance lookup ================================
174//
175// Caller holds a pointer to the account's latest is_current=1
176// snapshot. Returns balance_minor_q10. If account has no snapshots
177// yet, returns 0 (no transactions posted against it).
178
179func nx_balance_current_minor_q10(latest_snapshot: *BalanceSnapshot) -> i64 {
180 if latest_snapshot == 0 as *BalanceSnapshot { return 0 }
181 if latest_snapshot.is_current == 0 { return 0 } // superseded; caller bug
182 return latest_snapshot.balance_minor_q10
183}
184
185func nx_balance_current_verdict(latest_snapshot: *BalanceSnapshot) -> i64 {
186 if latest_snapshot == 0 as *BalanceSnapshot { return NX_BALANCE_ACCOUNT_NOT_FOUND }
187 return latest_snapshot.verdict
188}
189
190// ===== Sufficient-funds check (for marketplace + escrow flows) ===
191//
192// Composes against the latest snapshot. Returns 1 if account can
193// cover the requested debit; 0 otherwise.
194
195func nx_balance_can_debit(
196 latest_snapshot: *BalanceSnapshot,
197 debit_amount_q10: i64,
198 is_debit_normal: i64,
199 allow_overdraft: i64
200) -> i64 {
201 if latest_snapshot == 0 as *BalanceSnapshot { return 0 }
202 if allow_overdraft == 1 { return 1 } // some account kinds permit overdraft
203
204 let current: i64 = latest_snapshot.balance_minor_q10
205 // For debit-normal accounts (ASSET): can debit any positive amount;
206 // the debit increases balance. Always sufficient.
207 if is_debit_normal == 1 { return 1 }
208 // For credit-normal accounts (LIABILITY): a debit DECREASES the
209 // balance. Cannot decrease below zero unless overdraft allowed.
210 if current >= debit_amount_q10 { return 1 }
211 return 0
212}
213
214// ===== Historical balance at time T ===============================
215//
216// Walks the snapshot chain (each snapshot has superseded_by_hk
217// pointer) to find the snapshot with post_ts_unix <= target_unix.
218// Bounded loop per JPL Rule 2 cardinal.
219
220const NX_BALANCE_MAX_SNAPSHOT_WALK: i64 = 1048576
221
222func nx_balance_at_time(
223 latest_snapshot: *BalanceSnapshot,
224 target_unix: i64
225) -> i64 {
226 if latest_snapshot == 0 as *BalanceSnapshot { return 0 }
227 var cursor: *BalanceSnapshot = latest_snapshot
228 var iter: i64 = 0
229 var verdict: i64 = 0
230 while verdict == 0 && iter < NX_BALANCE_MAX_SNAPSHOT_WALK {
231 if cursor == 0 as *BalanceSnapshot { verdict = 1 }
232 if verdict == 0 {
233 if cursor.post_ts_unix <= target_unix { return cursor.balance_minor_q10 }
234 // Walk backward: each snapshot's prev is reachable via
235 // a separate per-account index (queued; v1 walks all
236 // snapshots for this account). Simplified: this primitive
237 // assumes caller passes the snapshot chain head pointer.
238 iter = iter + 1
239 }
240 }
241 return 0
242}
243
244// ===== Reconciliation primitive (periodic invariant check) ========
245//
246// Walks ALL ledger lines for account_id; sums debits/credits; asserts
247// matches latest snapshot's cumulative_*. Substrate runs this nightly.
248
249func nx_balance_reconcile(
250 latest_snapshot: *BalanceSnapshot,
251 ledger_lines: **LedgerLine,
252 n_lines: i64
253) -> i64 {
254 if latest_snapshot == 0 as *BalanceSnapshot { return NX_BALANCE_ACCOUNT_NOT_FOUND }
255 let target_account: i64 = latest_snapshot.account_id
256 let walked_balance: i64 = nx_ledger_account_balance_minor_q10(
257 target_account, ledger_lines, n_lines)
258 if walked_balance == latest_snapshot.balance_minor_q10 { return NX_BALANCE_POSITIVE }
259 // Cache drift detected — substrate-level alert
260 return NX_BALANCE_RECONCILING
261}