verifyhash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. package/trustledger/valueproof.js +350 -0
@@ -0,0 +1,1486 @@
1
+ "use strict";
2
+
3
+ // TrustLedger — reconcile.js
4
+ //
5
+ // T-22.3: the THREE-BALANCE check + exception classification.
6
+ //
7
+ // A real-estate broker who holds client money in a trust account is the legal
8
+ // custodian of that money. In every US state the broker must be able to prove,
9
+ // on demand, that THREE numbers agree:
10
+ //
11
+ // 1. BANK balance — what the bank statement says the trust account holds.
12
+ // 2. BOOK balance — what the broker's own ledger (QuickBooks) says it holds.
13
+ // 3. SUB-LEDGER — the SUM of every individual beneficiary's balance (each
14
+ // tenant's deposit/credit, each owner's held funds). The trust account is
15
+ // pooled, so the bank holds ONE number that must equal the sum of all the
16
+ // little per-beneficiary numbers underneath it.
17
+ //
18
+ // This is the "three-way reconciliation" the product sells. When all three tie
19
+ // out, the broker is clean. When they DON'T, the gap is the audit finding, and
20
+ // it is almost always explained by a small set of well-known RECONCILING ITEMS
21
+ // (timing) or genuine EXCEPTIONS (a real shortage/overage, an owner draw that
22
+ // touched a tenant's money, a bounced deposit, a security deposit that was not
23
+ // segregated). This module computes the three balances, decides whether they
24
+ // tie out, and CLASSIFIES every reconciling item / exception so a human sees
25
+ // exactly what to fix.
26
+ //
27
+ // PURE + DETERMINISTIC. Given the same inputs (and the upstream match.js result)
28
+ // it returns byte-identical output regardless of input order. No clock, no I/O,
29
+ // no randomness — the same property the matcher has, for the same reason: a
30
+ // reconciliation a broker signs and an auditor reads must be reproducible.
31
+ //
32
+ // -------------------------------------------------------------------------
33
+ // Sign convention (inherited from ingest.js):
34
+ // amount > 0 => money INTO the trust account (deposit, rent, owner funding)
35
+ // amount < 0 => money OUT of the trust account (check, draw, fee, refund)
36
+ // A "balance" here is therefore a running net of signed amounts plus a known
37
+ // opening balance.
38
+ // -------------------------------------------------------------------------
39
+ //
40
+ // Return shape:
41
+ // {
42
+ // balances: {
43
+ // bank: <int cents>, // opening + bank activity
44
+ // book: <int cents>, // opening + book activity
45
+ // subledger: <int cents>, // sum of per-beneficiary balances
46
+ // adjustedBank: <int cents>, // bank +/- outstanding items
47
+ // reconciled: <int cents>, // the single number all three should hit
48
+ // },
49
+ // tiesOut: <bool>, // do all three agree after reconciling items?
50
+ // exceptions: [
51
+ // { type, severity, amount, label, detail, records } , ...
52
+ // ],
53
+ // }
54
+ //
55
+ // `type` is one of EXCEPTION (a stable machine string). `severity` is "info"
56
+ // for benign timing items that EXPLAIN a gap vs. "error" for items that mean
57
+ // the trust account is actually out of trust (a shortage, commingling, an
58
+ // unsegregated deposit). `label` is a short human caption; `detail` a sentence.
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Enums
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const EXCEPTION = Object.freeze({
65
+ OUTSTANDING_DEPOSIT: "outstanding_deposit", // in book, not yet on bank (in transit)
66
+ OUTSTANDING_CHECK: "outstanding_check", // written in book, not yet cleared bank
67
+ NSF_REVERSAL: "nsf_reversal", // a bounced deposit reversed by the bank
68
+ OWNER_DRAW: "owner_draw", // owner pulled funds (must not dip into tenant money)
69
+ OWNER_OVERDRAW: "owner_overdraw", // owner drew MORE than their own contributed capital (tenant money)
70
+ SECURITY_DEPOSIT_SEGREGATION: "security_deposit_segregation", // deposit not held separately
71
+ AMBIGUOUS_DEPOSIT: "ambiguous_deposit", // a book deposit whose beneficiary type can't be determined
72
+ TIMING: "timing", // generic date-window timing difference
73
+ UNRECONCILED_BANK: "unreconciled_bank", // a bank line nothing explains
74
+ UNRECONCILED_BOOK: "unreconciled_book", // a book line nothing explains
75
+ SUBLEDGER_OUT_OF_BALANCE: "subledger_out_of_balance", // sum-of-tenants != book
76
+ NEGATIVE_TENANT_LEDGER: "negative_tenant_ledger", // an individual beneficiary balance is below zero
77
+ BANK_BOOK_MISMATCH: "bank_book_mismatch", // adjusted bank != book
78
+ CONTINUITY_BREAK: "continuity_break", // this period's opening != prior period's signed ending
79
+ });
80
+
81
+ const SEVERITY = Object.freeze({
82
+ INFO: "info", // a benign, self-clearing reconciling item (timing)
83
+ WARNING: "warning", // needs a human eye but may be legitimate
84
+ ERROR: "error", // trust account is out of trust: a real finding
85
+ });
86
+
87
+ // Map each exception type to its default severity. Timing/outstanding items are
88
+ // the normal, expected reconciling items (INFO). A draw, an unsegregated
89
+ // security deposit, an NSF, or any balance that fails to tie is a real finding.
90
+ const DEFAULT_SEVERITY = Object.freeze({
91
+ [EXCEPTION.OUTSTANDING_DEPOSIT]: SEVERITY.INFO,
92
+ [EXCEPTION.OUTSTANDING_CHECK]: SEVERITY.INFO,
93
+ [EXCEPTION.TIMING]: SEVERITY.INFO,
94
+ [EXCEPTION.NSF_REVERSAL]: SEVERITY.WARNING,
95
+ [EXCEPTION.OWNER_DRAW]: SEVERITY.WARNING,
96
+ // An owner that DRAWS more than its OWN contributed capital paid itself out of
97
+ // other beneficiaries' trust money (the excess). That is a conversion of trust
98
+ // funds and leaves the account out of trust REGARDLESS of whether the pooled
99
+ // sum still ties via the owner's negative control bucket, so it is an
100
+ // ERROR-grade finding by default. A state MAY re-grade it via policy.
101
+ [EXCEPTION.OWNER_OVERDRAW]: SEVERITY.ERROR,
102
+ [EXCEPTION.SECURITY_DEPOSIT_SEGREGATION]: SEVERITY.ERROR,
103
+ // A deposit whose beneficiary type we cannot determine (no recognizable
104
+ // keyword, not an explicitly-labeled rent/receipt) is a WARNING by default: it
105
+ // MIGHT be an un-segregated security deposit hiding as a generic deposit, so a
106
+ // human must look — but absent a security-deposit signal we do NOT escalate it
107
+ // to the out-of-trust ERROR a confirmed unsegregated deposit gets. A state MAY
108
+ // re-grade it via policy.
109
+ [EXCEPTION.AMBIGUOUS_DEPOSIT]: SEVERITY.WARNING,
110
+ [EXCEPTION.UNRECONCILED_BANK]: SEVERITY.WARNING,
111
+ [EXCEPTION.UNRECONCILED_BOOK]: SEVERITY.WARNING,
112
+ [EXCEPTION.SUBLEDGER_OUT_OF_BALANCE]: SEVERITY.ERROR,
113
+ // An individual beneficiary whose own sub-ledger balance is NEGATIVE means the
114
+ // trust account is short for that beneficiary — money the broker holds in trust
115
+ // FOR that person is not actually there (it was spent, or used to cover another
116
+ // beneficiary's shortfall). That is out of trust REGARDLESS of whether the
117
+ // pooled SUM still ties to the book, so it is an ERROR-grade finding by default.
118
+ [EXCEPTION.NEGATIVE_TENANT_LEDGER]: SEVERITY.ERROR,
119
+ [EXCEPTION.BANK_BOOK_MISMATCH]: SEVERITY.ERROR,
120
+ // A broken roll-forward means the books do not actually continue from the
121
+ // signed prior period — an out-of-trust-grade finding by default. A state MAY
122
+ // re-grade a documented timing roll-forward difference to a warning via policy.
123
+ [EXCEPTION.CONTINUITY_BREAK]: SEVERITY.ERROR,
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Errors
128
+ // ---------------------------------------------------------------------------
129
+
130
+ class ReconcileError extends Error {
131
+ constructor(message) {
132
+ super(message);
133
+ this.name = "ReconcileError";
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Small helpers
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function sumAmounts(records) {
142
+ let t = 0;
143
+ for (const r of records) {
144
+ if (!Number.isInteger(r.amount)) {
145
+ throw new ReconcileError("record.amount must be integer cents");
146
+ }
147
+ t += r.amount;
148
+ }
149
+ return t;
150
+ }
151
+
152
+ function isOwnerDraw(rec) {
153
+ const t = `${rec.memo || ""} ${rec.party || ""} ${rec.kind || ""}`.toLowerCase();
154
+ // An owner DRAW is money OUT, attributed to the owner (not a tenant/vendor).
155
+ return rec.amount < 0 && /\bowner\b|\bdraw\b|disbursement to owner|owner distribution/.test(t);
156
+ }
157
+
158
+ function isSecurityDeposit(rec) {
159
+ const t = `${rec.memo || ""} ${rec.party || ""} ${rec.kind || ""}`.toLowerCase();
160
+ return /security deposit|sec dep|sec\.? deposit|damage deposit|\bdeposit held\b/.test(t);
161
+ }
162
+
163
+ function isNsf(rec) {
164
+ if (rec.kind === "nsf") return true;
165
+ const t = `${rec.memo || ""} ${rec.kind || ""}`.toLowerCase();
166
+ return /\bnsf\b|returned|bounced|insufficient|reversal/.test(t);
167
+ }
168
+
169
+ // A CLOSED allowlist of purpose keywords that make a book inflow's beneficiary
170
+ // type RECOGNIZABLE. If a deposit's memo/kind matches ANY of these, we know what
171
+ // the money is (rent, an owner contribution, a refund, a fee credit, a payment
172
+ // installment, an explicit transfer, etc.), so it is NOT ambiguous. Security-
173
+ // deposit keywords are handled separately by isSecurityDeposit (which produces
174
+ // the ERROR-grade segregation finding) and so are intentionally NOT here — a
175
+ // recognizable security deposit must never be downgraded to a mere "recognized"
176
+ // inflow. Keeping the list CLOSED means a genuinely-unlabeled "Deposit - 12B
177
+ // Smith" stays LOUD instead of being silently swept into a generic bucket.
178
+ const RECOGNIZED_DEPOSIT_PURPOSE =
179
+ /\brent\b|\brents?\b|lease|tenant payment|\bpayment\b|\bpaid\b|partial|installment|instalment|\bowner\b|contribution|capital|reserve|distribution|\bdraw\b|\brefund\b|reimburs|\bfee\b|charge|interest|\bnsf\b|returned|bounced|reversal|transfer|segregat|escrow|in transit|in-transit|operating|management|commission|\bproceeds\b|payoff|\bach\b|wire|adjustment|correction|chargeback/;
180
+
181
+ // An EXPLICIT per-record marker that LABELS the deposit, so a labeled deposit /
182
+ // rent receipt is never flagged as ambiguous even if its free-text memo happens
183
+ // to lack a recognized keyword. Honored markers (any one suffices):
184
+ // * rec.kind === "rent" — an explicit rent receipt
185
+ // * rec.depositType is a non-empty str — the beneficiary type was stated
186
+ // * rec.ambiguous === false — the caller asserts it is determined
187
+ // * rec.expected === true — an expected/known line
188
+ // A marker is a deliberate, structured assertion by the producer of the row —
189
+ // distinct from us GUESSING from free text — so it is authoritative here.
190
+ function hasExplicitDepositLabel(rec) {
191
+ if (rec.kind === "rent") return true;
192
+ if (typeof rec.depositType === "string" && rec.depositType.trim() !== "") {
193
+ return true;
194
+ }
195
+ if (rec.ambiguous === false) return true;
196
+ if (rec.expected === true) return true;
197
+ return false;
198
+ }
199
+
200
+ // A book deposit whose BENEFICIARY TYPE cannot be determined: a deposit-scale
201
+ // INFLOW that calls itself a "deposit" (the word, or kind === "deposit") but
202
+ // carries NO recognized purpose keyword and is NOT an explicitly-labeled
203
+ // rent/receipt — so we cannot tell whether it is rent, an owner contribution, or
204
+ // an un-segregated security deposit hiding as a generic deposit. We REQUIRE a
205
+ // party (an attributed beneficiary) so a bare bank-statement "Deposit" line with
206
+ // no counterparty is not over-flagged. A record that already matches
207
+ // isSecurityDeposit is NOT ambiguous — it is a recognized security deposit and is
208
+ // handled (as an ERROR) by classifySecurityDeposits; flagging it here too would
209
+ // double-count the same row. PURE: free-text classification only — no fs, no
210
+ // http, no ethers, no clock.
211
+ function isAmbiguousDeposit(rec) {
212
+ if (!rec) return false;
213
+ if (!Number.isInteger(rec.amount) || rec.amount <= 0) return false; // inflow only
214
+ if (hasExplicitDepositLabel(rec)) return false; // labeled => determined
215
+ if (isSecurityDeposit(rec)) return false; // recognized sec dep => not ambiguous
216
+ if (isOwnerDraw(rec) || isNsf(rec)) return false; // recognized otherwise
217
+ const party = String(rec.party || "").trim();
218
+ if (party === "") return false; // unattributed bare line: don't over-flag
219
+ const t = `${rec.memo || ""} ${rec.kind || ""}`.toLowerCase();
220
+ // It must call itself a deposit (the only signal we have), ...
221
+ const callsItselfDeposit = /\bdeposit\b/.test(t) || rec.kind === "deposit";
222
+ if (!callsItselfDeposit) return false;
223
+ // ... and offer NO recognized purpose to disambiguate it.
224
+ if (RECOGNIZED_DEPOSIT_PURPOSE.test(t)) return false;
225
+ return true;
226
+ }
227
+
228
+ // The canonical, order-independent per-BENEFICIARY key for a record's party.
229
+ // Mirrors the sub-ledger's own normalization (tenantBalances uses the SAME
230
+ // `String(party).trim()` convention) so a deposit and a segregation transfer
231
+ // that name the same tenant bucket together. Case-folded + whitespace-collapsed
232
+ // so "Jones (4B)" and "jones (4b)" are ONE beneficiary. An empty/absent party
233
+ // normalizes to "" — the sentinel for "no attributable beneficiary", which the
234
+ // segregation matcher treats as covering NOTHING on its own. PURE.
235
+ function partyKey(party) {
236
+ return String(party == null ? "" : party)
237
+ .trim()
238
+ .toLowerCase()
239
+ .replace(/\s+/g, " ");
240
+ }
241
+
242
+ // A canonical, order-independent sort key for a record (date, amount, memo).
243
+ function recKey(r) {
244
+ return `${r.date}|${String(r.amount).padStart(16, "0")}|${(r.memo || "")
245
+ .toLowerCase()
246
+ .trim()}|${(r.party || "").toLowerCase().trim()}`;
247
+ }
248
+
249
+ function pushException(out, ex) {
250
+ out.push({
251
+ type: ex.type,
252
+ severity: ex.severity || DEFAULT_SEVERITY[ex.type] || SEVERITY.WARNING,
253
+ amount: ex.amount,
254
+ label: ex.label,
255
+ detail: ex.detail,
256
+ records: ex.records || [],
257
+ });
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Core
262
+ // ---------------------------------------------------------------------------
263
+
264
+ // Inputs:
265
+ // bank: NormalizedRecord[] (source = bank)
266
+ // book: NormalizedRecord[] (source = quickbooks)
267
+ // tenants: either
268
+ // - NormalizedRecord[] (rent-roll rows; we net per party), OR
269
+ // - { [party]: <balanceCents> } (precomputed per-tenant balances)
270
+ // opts:
271
+ // opening: { bank, book } opening balances in cents (default 0/0)
272
+ // matchResult: the object returned by match.reconcile(bank, book) — used to
273
+ // identify which lines are reconciling items (outstanding /
274
+ // in-transit) vs. genuinely unexplained exceptions. Optional;
275
+ // if omitted we classify purely from the records themselves.
276
+ // toleranceCents: how far the three may differ and still "tie out" (default 0;
277
+ // money ties to the penny — a non-zero tolerance must be a
278
+ // deliberate caller choice).
279
+ function reconcile(bank, book, tenants, opts = {}) {
280
+ if (!Array.isArray(bank)) throw new ReconcileError("bank must be an array");
281
+ if (!Array.isArray(book)) throw new ReconcileError("book must be an array");
282
+ const cfg = {
283
+ opening: { bank: 0, book: 0 },
284
+ matchResult: null,
285
+ toleranceCents: 0,
286
+ ...opts,
287
+ };
288
+ cfg.opening = { bank: 0, book: 0, ...(opts.opening || {}) };
289
+ if (!Number.isInteger(cfg.toleranceCents) || cfg.toleranceCents < 0) {
290
+ throw new ReconcileError("toleranceCents must be a non-negative integer");
291
+ }
292
+ if (
293
+ !Number.isInteger(cfg.opening.bank) ||
294
+ !Number.isInteger(cfg.opening.book)
295
+ ) {
296
+ throw new ReconcileError("opening balances must be integer cents");
297
+ }
298
+
299
+ // -- The three raw balances. ---------------------------------------------
300
+ const bankBalance = cfg.opening.bank + sumAmounts(bank);
301
+ const bookBalance = cfg.opening.book + sumAmounts(book);
302
+ const subBalances = tenantBalances(tenants);
303
+ const subledgerBalance = Object.values(subBalances).reduce((a, b) => a + b, 0);
304
+
305
+ const exceptions = [];
306
+
307
+ // -- Identify reconciling items from the matcher (timing in/out of bank). -
308
+ // Anything in the BOOK that the matcher could not pair to a BANK line is
309
+ // "outstanding": the book knows about it but the bank doesn't yet. A positive
310
+ // such item is a deposit in transit; a negative one is an outstanding check.
311
+ // Anything on the BANK with no book partner is a bank-only line (a fee the
312
+ // bookkeeper hasn't recorded, or an NSF reversal) — also a reconciling item,
313
+ // classified by what it is.
314
+ let outstandingDeposits = 0;
315
+ let outstandingChecks = 0;
316
+
317
+ // The caller runs match.reconcile(bank, book), so listA == bank (=> unmatchedA)
318
+ // and listB == book (=> unmatchedB).
319
+ const unmatchedBank = matcherUnmatched(cfg.matchResult, "unmatchedA", bank, book, "bank");
320
+ const unmatchedBook = matcherUnmatched(cfg.matchResult, "unmatchedB", bank, book, "book");
321
+
322
+ // Book-only lines => outstanding items (book ahead of bank).
323
+ for (const r of [...unmatchedBook].sort((x, y) => cmp(recKey(x), recKey(y)))) {
324
+ if (isNsf(r)) {
325
+ pushException(exceptions, {
326
+ type: EXCEPTION.NSF_REVERSAL,
327
+ amount: r.amount,
328
+ label: "NSF / returned-item reversal (book)",
329
+ detail:
330
+ "A bounced or returned item recorded in the book; confirm the bank " +
331
+ "posted the matching reversal and the tenant balance was re-debited.",
332
+ records: [r],
333
+ });
334
+ continue;
335
+ }
336
+ if (r.amount > 0) {
337
+ outstandingDeposits += r.amount;
338
+ pushException(exceptions, {
339
+ type: EXCEPTION.OUTSTANDING_DEPOSIT,
340
+ amount: r.amount,
341
+ label: "Deposit in transit",
342
+ detail:
343
+ "Recorded in the book but not yet on the bank statement; expected to " +
344
+ "clear in the next few days (timing).",
345
+ records: [r],
346
+ });
347
+ } else if (r.amount < 0) {
348
+ outstandingChecks += r.amount; // negative
349
+ pushException(exceptions, {
350
+ type: isOwnerDraw(r) ? EXCEPTION.OWNER_DRAW : EXCEPTION.OUTSTANDING_CHECK,
351
+ amount: r.amount,
352
+ label: isOwnerDraw(r) ? "Outstanding owner draw" : "Outstanding check",
353
+ detail: isOwnerDraw(r)
354
+ ? "An owner draw written in the book that has not yet cleared the bank; " +
355
+ "verify it does not draw against tenant or security-deposit funds."
356
+ : "A check written in the book that has not yet cleared the bank (timing).",
357
+ records: [r],
358
+ });
359
+ }
360
+ }
361
+
362
+ // Bank-only lines => the bank knows something the book doesn't yet.
363
+ for (const r of [...unmatchedBank].sort((x, y) => cmp(recKey(x), recKey(y)))) {
364
+ if (isNsf(r)) {
365
+ pushException(exceptions, {
366
+ type: EXCEPTION.NSF_REVERSAL,
367
+ amount: r.amount,
368
+ label: "NSF / returned-item reversal (bank)",
369
+ detail:
370
+ "The bank reversed a deposit that bounced; the book must record the " +
371
+ "reversal and re-debit the tenant's sub-ledger.",
372
+ records: [r],
373
+ });
374
+ } else {
375
+ pushException(exceptions, {
376
+ type: EXCEPTION.UNRECONCILED_BANK,
377
+ amount: r.amount,
378
+ label: "Unreconciled bank line",
379
+ detail:
380
+ "A bank transaction with no matching book entry; record it in the " +
381
+ "book or explain it before signing.",
382
+ records: [r],
383
+ });
384
+ }
385
+ }
386
+
387
+ // -- Adjusted bank balance: bank +/- the outstanding (in-transit) items. --
388
+ // adjustedBank = bank + deposits-in-transit + outstanding-checks(negative).
389
+ // After this adjustment the bank should equal the book.
390
+ const adjustedBank = bankBalance + outstandingDeposits + outstandingChecks;
391
+
392
+ // -- Classify owner draws and security-deposit segregation across ALL book
393
+ // activity (not only the unmatched), since these are policy findings about
394
+ // what the money WAS, independent of whether the line cleared. -----------
395
+ classifyOwnerDraws(book, subBalances, exceptions, cfg.toleranceCents);
396
+ classifySecurityDeposits(book, bank, exceptions);
397
+ // A deposit whose beneficiary type can't be determined is a LOUD WARNING, but
398
+ // only AFTER classifySecurityDeposits has had its say: isAmbiguousDeposit
399
+ // excludes anything isSecurityDeposit recognizes, so a confirmed un-segregated
400
+ // security deposit raises ONLY the ERROR-grade segregation finding (no
401
+ // double-count), while a previously-silent generic-looking deposit is no
402
+ // longer swept under the rug.
403
+ classifyAmbiguousDeposits(book, exceptions);
404
+
405
+ // -- The three-way tie-out. ----------------------------------------------
406
+ // After reconciling items, adjustedBank should equal book, and book should
407
+ // equal the sum of the sub-ledgers.
408
+ const tol = cfg.toleranceCents;
409
+ const bankBookGap = adjustedBank - bookBalance;
410
+ const bookSubGap = bookBalance - subledgerBalance;
411
+
412
+ if (Math.abs(bankBookGap) > tol) {
413
+ pushException(exceptions, {
414
+ type: EXCEPTION.BANK_BOOK_MISMATCH,
415
+ amount: bankBookGap,
416
+ label: "Adjusted bank does not equal book",
417
+ detail:
418
+ `After outstanding items the bank balance (${adjustedBank}) and the ` +
419
+ `book balance (${bookBalance}) differ by ${bankBookGap} cents; an ` +
420
+ "unrecorded transaction or a posting error remains.",
421
+ records: [],
422
+ });
423
+ }
424
+ if (Math.abs(bookSubGap) > tol) {
425
+ pushException(exceptions, {
426
+ type: EXCEPTION.SUBLEDGER_OUT_OF_BALANCE,
427
+ amount: bookSubGap,
428
+ label: "Sum of tenant sub-ledgers does not equal book",
429
+ detail:
430
+ `The book balance (${bookBalance}) and the sum of all beneficiary ` +
431
+ `sub-ledger balances (${subledgerBalance}) differ by ${bookSubGap} ` +
432
+ "cents; the trust account is out of trust until this is resolved.",
433
+ records: [],
434
+ });
435
+ }
436
+
437
+ // -- Per-beneficiary negative-ledger check (T-41.1). ----------------------
438
+ // ORTHOGONAL to the pooled SUBLEDGER_OUT_OF_BALANCE check above: the SUM of all
439
+ // sub-ledgers can tie perfectly to the book while an INDIVIDUAL beneficiary's
440
+ // balance is negative — one tenant's surplus masking another tenant's deficit
441
+ // in the pooled total. A negative individual ledger means the broker is holding
442
+ // LESS than zero in trust for that person: their money was spent or used to
443
+ // cover someone else. That is out of trust on its own, so flag it regardless of
444
+ // whether the SUM ties. This can only ADD findings (it never removes one), so
445
+ // it is strictly non-looser. A control/sink account is excluded only when it is
446
+ // STRUCTURALLY marked (`controlAccount: true`, authoritative) or its name leads
447
+ // with a control word; a real beneficiary whose name merely contains a control
448
+ // token in a non-leading position is no longer silently dropped.
449
+ classifyNegativeTenantLedgers(subBalances, tol, exceptions, controlAccountKeys(tenants));
450
+
451
+ const tiesOut =
452
+ Math.abs(bankBookGap) <= tol && Math.abs(bookSubGap) <= tol;
453
+
454
+ // Stable, deterministic ordering of exceptions: by severity (errors first),
455
+ // then type, then amount, then a record key — independent of detection order.
456
+ exceptions.sort(compareExceptions);
457
+
458
+ return {
459
+ balances: {
460
+ bank: bankBalance,
461
+ book: bookBalance,
462
+ subledger: subledgerBalance,
463
+ adjustedBank,
464
+ reconciled: tiesOut ? bookBalance : null,
465
+ },
466
+ tiesOut,
467
+ exceptions,
468
+ };
469
+ }
470
+
471
+ // Build a CONTINUITY_BREAK exception from a non-zero roll-forward gap. PURE.
472
+ // `cont` is the structured result of close.checkContinuity(priorClose, opening):
473
+ // { ok, bankGap, bookGap }. The exception carries the bank gap in `amount` (the
474
+ // headline number) and BOTH gaps + the prior period label in `detail`, so an
475
+ // auditor reads exactly which leg failed to roll forward and by how much. The
476
+ // severity is the DEFAULT_SEVERITY for the type (error) unless the caller's
477
+ // policy later overrides it — it flows through the SAME applyPolicy path as
478
+ // every other exception. Returns null when there is no gap (ok), so the caller
479
+ // can simply skip a null.
480
+ function buildContinuityException(cont, priorPeriodLabel) {
481
+ if (!cont || cont.ok) return null;
482
+ const bankGap = Number.isInteger(cont.bankGap) ? cont.bankGap : 0;
483
+ const bookGap = Number.isInteger(cont.bookGap) ? cont.bookGap : 0;
484
+ const priorName =
485
+ priorPeriodLabel == null || String(priorPeriodLabel).trim() === ""
486
+ ? "the prior period"
487
+ : `prior period "${String(priorPeriodLabel)}"`;
488
+ return {
489
+ type: EXCEPTION.CONTINUITY_BREAK,
490
+ severity: DEFAULT_SEVERITY[EXCEPTION.CONTINUITY_BREAK],
491
+ amount: bankGap,
492
+ label: "Roll-forward continuity break",
493
+ detail:
494
+ `This period's opening balances do not roll forward from ${priorName}: ` +
495
+ `the bank opening differs from the prior ending by ${bankGap} cents and ` +
496
+ `the book opening differs by ${bookGap} cents. A non-zero gap means a ` +
497
+ "period was skipped, edited, or re-keyed and the chain of custody over " +
498
+ "the trust money is broken; reconcile the opening to the prior signed " +
499
+ "ending before relying on this packet.",
500
+ records: [],
501
+ };
502
+ }
503
+
504
+ function cmp(a, b) {
505
+ return a < b ? -1 : a > b ? 1 : 0;
506
+ }
507
+
508
+ // The canonical, stable severity-first ordering of an exception list: errors
509
+ // before warnings before info, then by type, then by amount, then by a record
510
+ // key — independent of detection order. Exported (and reused by policy.js's
511
+ // applyPolicy) so the order an auditor reads is computed in ONE place and a
512
+ // freshly-escalated ERROR re-sorts to the top of the table the same way a
513
+ // natively-detected one does. Pure: a comparator over two exceptions.
514
+ const SEV_RANK = Object.freeze({ error: 0, warning: 1, info: 2 });
515
+ function compareExceptions(a, b) {
516
+ const sa = SEV_RANK[a.severity] ?? 3;
517
+ const sb = SEV_RANK[b.severity] ?? 3;
518
+ if (sa !== sb) return sa - sb;
519
+ if (a.type !== b.type) return cmp(a.type, b.type);
520
+ if (a.amount !== b.amount) return a.amount - b.amount;
521
+ const ka = a.records && a.records[0] ? recKey(a.records[0]) : "";
522
+ const kb = b.records && b.records[0] ? recKey(b.records[0]) : "";
523
+ return cmp(ka, kb);
524
+ }
525
+
526
+ // Net the rent-roll rows into a per-beneficiary balance map, or accept a
527
+ // precomputed { party -> cents } map directly.
528
+ function tenantBalances(tenants) {
529
+ if (tenants == null) return {};
530
+ if (!Array.isArray(tenants)) {
531
+ // Precomputed balance map.
532
+ const out = {};
533
+ for (const [k, v] of Object.entries(tenants)) {
534
+ if (!Number.isInteger(v)) {
535
+ throw new ReconcileError(`tenant balance for "${k}" must be integer cents`);
536
+ }
537
+ out[k] = v;
538
+ }
539
+ return out;
540
+ }
541
+ const out = {};
542
+ for (const r of tenants) {
543
+ const party = String(r.party || "unknown").trim() || "unknown";
544
+ if (!Number.isInteger(r.amount)) {
545
+ throw new ReconcileError("tenant record.amount must be integer cents");
546
+ }
547
+ out[party] = (out[party] || 0) + r.amount;
548
+ }
549
+ return out;
550
+ }
551
+
552
+ // Pull the matcher's unmatched list for one side, falling back to "everything"
553
+ // when no matchResult was supplied (so the function still works standalone).
554
+ function matcherUnmatched(matchResult, key, bank, book, side) {
555
+ if (matchResult && Array.isArray(matchResult[key])) {
556
+ return matchResult[key];
557
+ }
558
+ // No matcher result: derive a best-effort reconciling set by cancelling out
559
+ // bank/book lines that share the same (amount) so only the genuine residue
560
+ // remains. This keeps reconcile() useful on its own while staying deterministic.
561
+ if (!matchResult) {
562
+ return residue(side === "bank" ? bank : book, side === "bank" ? book : bank);
563
+ }
564
+ return [];
565
+ }
566
+
567
+ // Multiset difference by amount: return the records in `self` whose amounts are
568
+ // not cancelled by an equal amount in `other`. Deterministic.
569
+ function residue(self, other) {
570
+ const otherCounts = new Map();
571
+ for (const r of other) {
572
+ otherCounts.set(r.amount, (otherCounts.get(r.amount) || 0) + 1);
573
+ }
574
+ const out = [];
575
+ for (const r of [...self].sort((x, y) => cmp(recKey(x), recKey(y)))) {
576
+ const c = otherCounts.get(r.amount) || 0;
577
+ if (c > 0) {
578
+ otherCounts.set(r.amount, c - 1);
579
+ } else {
580
+ out.push(r);
581
+ }
582
+ }
583
+ return out;
584
+ }
585
+
586
+ // Owner draws (T-22.3 + T-42.1). TWO findings, computed in ONE pass over the
587
+ // owner activity in the book:
588
+ //
589
+ // 1. OWNER_DRAW (warning, per line) — every owner-draw line is classified so a
590
+ // human confirms it was paid only from the owner's OWN funds.
591
+ //
592
+ // 2. OWNER_OVERDRAW (ERROR, per owner account) — when an owner DRAWS MORE than
593
+ // that owner CONTRIBUTED in this period's book, the EXCESS is tenant money:
594
+ // the owner paid themselves out of someone else's trust funds. This is the
595
+ // single most-prosecuted residential-PM trust violation (conversion), and
596
+ // before T-42.1 it was a SILENT PASS whenever the owner was modeled as a
597
+ // control-account sub-ledger party — the pooled SUM still ties to the book
598
+ // via the owner's negative bucket, and the EPIC-41 negative-ledger check
599
+ // deliberately EXCLUDES control/owner accounts (an owner's negative is
600
+ // structural WHILE it stays within the owner's own contributed capital).
601
+ // That EPIC-41 exclusion was UNBOUNDED: it also swallowed the negative
602
+ // BEYOND contributed capital. OWNER_OVERDRAW is the precise INVERSE: EPIC-41
603
+ // keeps ignoring the negative WITHIN contributed capital (the owner
604
+ // legitimately deploying their OWN funds, which every existing owner-draw
605
+ // test exercises and which stays PASS), and this check catches only the
606
+ // negative BEYOND it.
607
+ //
608
+ // Per owner account (keyed by the draw's party, case-folded):
609
+ // C = contributed capital = the sum of that account's OWN positive book inflows
610
+ // in this period (the basis the owner is entitled to draw against).
611
+ // D = total draws = the sum of |amount| of that account's owner-draw
612
+ // lines in the book.
613
+ // B = the account's sub-ledger balance (how negative the owner actually went).
614
+ // The over-capital excess is `D - C`, BOUNDED by the owner's actual negative
615
+ // `-B` so we never claim more tenant money than is genuinely missing. We only
616
+ // assess overdraw when the owner ESTABLISHED an in-period contribution basis
617
+ // (`C > 0`): absent any in-period contribution, the sub-ledger negative is
618
+ // treated as legitimate OPENING owner capital being deployed (the EPIC-41
619
+ // boundary) and is NOT second-guessed from a name. `toleranceCents` is honored
620
+ // (an excess at or below tolerance is not flagged). This can only ADD a finding,
621
+ // never remove one, so it is STRICTLY non-looser. PURE + order-independent.
622
+ function classifyOwnerDraws(book, subBalances, exceptions, toleranceCents) {
623
+ const tol = Number.isInteger(toleranceCents) && toleranceCents >= 0 ? toleranceCents : 0;
624
+
625
+ // Per-owner-account accumulation of draws (D) and contributed capital (C),
626
+ // keyed by the case-folded party so a deposit and a draw that name the same
627
+ // owner account aggregate together. Order-independent.
628
+ const draws = new Map(); // ownerKey -> total |draw amount|
629
+ const capital = new Map(); // ownerKey -> total positive book inflow
630
+ const drawRecords = new Map(); // ownerKey -> the owner-draw record sorted first (for naming)
631
+
632
+ const sortedBook = [...book].sort((x, y) => cmp(recKey(x), recKey(y)));
633
+
634
+ for (const r of sortedBook) {
635
+ if (isOwnerDraw(r)) {
636
+ const key = partyKey(r.party);
637
+ draws.set(key, (draws.get(key) || 0) + Math.abs(r.amount));
638
+ if (!drawRecords.has(key)) drawRecords.set(key, r);
639
+ // Already emitted as OUTSTANDING owner draw above if it was unmatched; emit
640
+ // the policy-level OWNER_DRAW classification here once, deduped by record.
641
+ if (
642
+ exceptions.some(
643
+ (e) => e.type === EXCEPTION.OWNER_DRAW && e.records[0] && recKey(e.records[0]) === recKey(r)
644
+ )
645
+ ) {
646
+ continue;
647
+ }
648
+ pushException(exceptions, {
649
+ type: EXCEPTION.OWNER_DRAW,
650
+ amount: r.amount,
651
+ label: "Owner draw",
652
+ detail:
653
+ "A disbursement to the property owner; confirm it is paid only from " +
654
+ "that owner's own funds and never from tenant or security-deposit money.",
655
+ records: [r],
656
+ });
657
+ } else if (Number.isInteger(r.amount) && r.amount > 0) {
658
+ // A positive book inflow attributed to a party is that party's contributed
659
+ // capital basis (only consulted for parties that also have owner draws).
660
+ const key = partyKey(r.party);
661
+ if (key !== "") capital.set(key, (capital.get(key) || 0) + r.amount);
662
+ }
663
+ }
664
+
665
+ // The owner's sub-ledger balance per case-folded key (an owner account may be
666
+ // spelled with different casing in the sub-ledger than in a book line; fold).
667
+ const ownerBalance = new Map();
668
+ for (const [party, bal] of Object.entries(subBalances)) {
669
+ if (!Number.isInteger(bal)) continue;
670
+ const key = partyKey(party);
671
+ ownerBalance.set(key, (ownerBalance.get(key) || 0) + bal);
672
+ }
673
+
674
+ // OWNER_OVERDRAW: per owner account, in a stable key-sorted order.
675
+ for (const key of [...draws.keys()].sort(cmp)) {
676
+ const D = draws.get(key) || 0;
677
+ const C = capital.get(key) || 0;
678
+ if (C <= 0) continue; // no in-period basis: EPIC-41 boundary, not second-guessed
679
+ const overCapital = D - C;
680
+ if (overCapital <= 0) continue; // drew at or below contributed capital — fine
681
+ // Bound by how negative the owner actually went, so we never claim more
682
+ // tenant money than is genuinely missing (e.g. opening owner capital covered
683
+ // part of it). When no owner bucket exists, the full over-capital amount is
684
+ // the unbacked excess.
685
+ const B = ownerBalance.has(key) ? ownerBalance.get(key) : -overCapital;
686
+ const shortfall = B < 0 ? -B : 0;
687
+ const excess = Math.min(overCapital, shortfall);
688
+ if (excess <= tol) continue; // within tolerance (or fully backed by capital)
689
+
690
+ const r = drawRecords.get(key);
691
+ const who = beneficiaryLabel(r ? r.party : "");
692
+ pushException(exceptions, {
693
+ type: EXCEPTION.OWNER_OVERDRAW,
694
+ amount: excess, // the EXCESS (tenant money consumed), a positive cents figure
695
+ label: "Owner draw exceeds contributed capital",
696
+ detail:
697
+ `Owner account ${who} drew ${fmtCentsForDetail(D)} against only ` +
698
+ `${fmtCentsForDetail(C)} of its OWN contributed capital, so ` +
699
+ `${fmtCentsForDetail(excess)} of the draw was paid out of other ` +
700
+ "beneficiaries' trust money. An owner may be disbursed only from their " +
701
+ "own funds; paying an owner out of tenant or security-deposit money is a " +
702
+ "conversion of trust funds and leaves the account out of trust. Restore " +
703
+ `${fmtCentsForDetail(excess)} to the trust account before relying on this packet.`,
704
+ records: r ? [r] : [],
705
+ });
706
+ }
707
+ }
708
+
709
+ // A segregation movement is an OUTFLOW whose memo/kind references segregation /
710
+ // transfer of the deposit. PURE: free-text classification only.
711
+ function isSegregationMove(r) {
712
+ const t = `${r.memo || ""} ${r.kind || ""}`.toLowerCase();
713
+ return (
714
+ r.amount < 0 &&
715
+ (/segregat|transfer to (security|escrow|trust)|to security deposit account|escrow/.test(t) ||
716
+ (isSecurityDeposit(r) && /transfer|segregat|escrow/.test(t)))
717
+ );
718
+ }
719
+
720
+ // Tokenize a string into lowercase WHOLE word tokens (alphanumeric runs). The
721
+ // boundary is /[^a-z0-9]+/ so punctuation, spaces, and unit markers separate
722
+ // tokens. Used to make beneficiary-name matching WORD-BOUNDED instead of a raw
723
+ // substring test. PURE.
724
+ function nameTokens(s) {
725
+ return String(s == null ? "" : s)
726
+ .toLowerCase()
727
+ .split(/[^a-z0-9]+/)
728
+ .filter((t) => t !== "");
729
+ }
730
+
731
+ // Build a name-matching index over the beneficiary keys ONCE per reconcile pass.
732
+ // The index lets a transfer's memo be matched against beneficiary names in time
733
+ // proportional to the MEMO length (not the number of beneficiaries D), closing
734
+ // the O(D × T) algorithmic-complexity blowup the per-transfer O(D) scan caused on
735
+ // the untrusted-upload endpoint. Structure: a Map from each key's FIRST token to
736
+ // the list of { key, tokens } whose name begins with that token. A memo can then
737
+ // be probed by walking its tokens once and, at each position, checking only the
738
+ // keys that share that starting token. PURE.
739
+ function buildDepositNameIndex(depositKeys) {
740
+ const byFirstToken = new Map();
741
+ for (const key of depositKeys) {
742
+ if (key === "") continue;
743
+ const tokens = nameTokens(key);
744
+ if (tokens.length === 0) continue; // a key with no word characters cannot be named in a memo
745
+ const first = tokens[0];
746
+ let bucket = byFirstToken.get(first);
747
+ if (!bucket) {
748
+ bucket = [];
749
+ byFirstToken.set(first, bucket);
750
+ }
751
+ bucket.push({ key, tokens });
752
+ }
753
+ return byFirstToken;
754
+ }
755
+
756
+ // Does `memoTokens` contain `keyTokens` as a CONTIGUOUS run starting at index i?
757
+ function tokensMatchAt(memoTokens, i, keyTokens) {
758
+ if (i + keyTokens.length > memoTokens.length) return false;
759
+ for (let j = 0; j < keyTokens.length; j++) {
760
+ if (memoTokens[i + j] !== keyTokens[j]) return false;
761
+ }
762
+ return true;
763
+ }
764
+
765
+ // Attribute a segregation transfer to the BENEFICIARY whose deposit it covers.
766
+ // Trust law segregates EACH tenant's deposit SEPARATELY, so a transfer's coverage
767
+ // must be pinned to one beneficiary — it can NEVER spill its excess onto another
768
+ // tenant's un-segregated deposit. Two signals, in priority order:
769
+ // 1. The transfer's own `party` field, if it names a beneficiary that actually
770
+ // holds a security deposit (the structured, authoritative signal).
771
+ // 2. Failing that, a beneficiary NAME appearing in the transfer's memo (so a
772
+ // "Transfer Jones security deposit to escrow" line still attributes even
773
+ // with no party column).
774
+ // A transfer that matches NEITHER is GENERIC (returns "") — it provides only a
775
+ // residual pool that the existing same-amount mirror tests rely on, and is the
776
+ // fail-loud sentinel for an unattributable transfer (it can clear at most a
777
+ // still-uncovered deposit, never silently absorb one tenant's shortage into
778
+ // another's surplus).
779
+ //
780
+ // The memo fallback is WORD-BOUNDED: a key matches only as a contiguous run of
781
+ // WHOLE memo tokens, never as an incidental substring. A raw `memo.includes(key)`
782
+ // mis-pins ordinary surnames to standard segregation vocabulary — "escrow"
783
+ // contains "crow", "transfer" contains "tran" — silently stranding generic
784
+ // coverage on, or falsely clearing, an unrelated real tenant. Whole-token
785
+ // matching makes "Transfer to escrow" attribute to NOBODY (no token equals a
786
+ // beneficiary key), leaving it correctly in the generic residual pool.
787
+ //
788
+ // `index` is the prebuilt Map from buildDepositNameIndex (first token -> keys),
789
+ // so attribution is O(memo tokens), not O(beneficiaries). Among all matching
790
+ // keys the LONGEST (most tokens, then longest string) wins, deterministically, so
791
+ // "jones (4b)" beats a bare "jones". PURE + deterministic.
792
+ function attributeSegregation(transfer, depositKeys, index) {
793
+ const own = partyKey(transfer.party);
794
+ if (own !== "" && depositKeys.has(own)) return own;
795
+ const memoTokens = nameTokens(transfer.memo);
796
+ if (memoTokens.length === 0) return "";
797
+ let best = "";
798
+ let bestTokenLen = 0;
799
+ for (let i = 0; i < memoTokens.length; i++) {
800
+ const bucket = index.get(memoTokens[i]);
801
+ if (!bucket) continue;
802
+ for (const { key, tokens } of bucket) {
803
+ if (!tokensMatchAt(memoTokens, i, tokens)) continue;
804
+ // Longest match wins: more tokens first, then longer key string, so the
805
+ // tie-break is total and order-independent across the bucket.
806
+ if (
807
+ tokens.length > bestTokenLen ||
808
+ (tokens.length === bestTokenLen && key.length > best.length)
809
+ ) {
810
+ best = key;
811
+ bestTokenLen = tokens.length;
812
+ }
813
+ }
814
+ }
815
+ return best;
816
+ }
817
+
818
+ // Security-deposit segregation: every security-deposit RECEIPT recorded in the
819
+ // book must have a corresponding movement OUT to a segregated account (or be
820
+ // flagged as held separately). If a security-deposit inflow is sitting in the
821
+ // operating/pooled book with no offsetting segregation transfer, that is a
822
+ // compliance finding in many states.
823
+ //
824
+ // PER-BENEFICIARY MATCHING (T-40.1). Trust law requires EACH tenant's deposit be
825
+ // held SEPARATELY, so coverage is matched PER BENEFICIARY — never from a single
826
+ // pooled total. Concretely: a segregation transfer attributed to tenant X covers
827
+ // ONLY X's deposits; its excess does NOT spill onto another tenant Y's
828
+ // un-segregated deposit (the false-negative the pooled FIFO produced). This can
829
+ // only ADD or RE-ATTRIBUTE a finding versus the old pooled sum — never remove a
830
+ // real one — so it is STRICTLY non-looser.
831
+ function classifySecurityDeposits(book, bank, exceptions) {
832
+ // Find security-deposit inflows in the book, grouped by beneficiary key.
833
+ const secDeposits = [...book]
834
+ .filter((r) => isSecurityDeposit(r) && r.amount > 0)
835
+ .sort((x, y) => cmp(recKey(x), recKey(y)));
836
+ if (secDeposits.length === 0) return;
837
+
838
+ // The set of beneficiary keys that actually hold a security deposit — the only
839
+ // keys a transfer may attribute to. The name index is built ONCE here so the
840
+ // per-transfer memo fallback is O(memo length), not O(beneficiaries).
841
+ const depositKeys = new Set(secDeposits.map((r) => partyKey(r.party)));
842
+ const depositNameIndex = buildDepositNameIndex(depositKeys);
843
+
844
+ // CRITICAL: count each segregation transfer from ONE authoritative source —
845
+ // the BOOK — never from both book and bank. A single real segregation transfer
846
+ // is recorded twice (once in QuickBooks, once on the bank statement) because it
847
+ // is the same money movement seen from two sources. Summing across both sources
848
+ // double-counts one $X transfer as $2X of coverage, which can SILENTLY CLEAR a
849
+ // genuinely un-segregated security deposit — a false negative on the flagship
850
+ // finding this product exists to catch. The bank-side copy is the mirror of the
851
+ // same movement (match.js pairs them); it adds no NEW segregation, so it must
852
+ // NOT add coverage. `bank` is therefore intentionally unused for the sum.
853
+ void bank;
854
+
855
+ // Bucket each book segregation move's coverage by the beneficiary it is
856
+ // attributed to. A GENERIC (unattributable) transfer (key "") goes into a
857
+ // residual pool applied ONLY to deposits no attributed transfer covered — it
858
+ // can clear at most a still-uncovered deposit, never silently net one tenant's
859
+ // shortage against another tenant's surplus.
860
+ const coveredByParty = new Map(); // key -> cents available
861
+ let genericPool = 0;
862
+ for (const r of [...book]
863
+ .filter(isSegregationMove)
864
+ .sort((x, y) => cmp(recKey(x), recKey(y)))) {
865
+ const key = attributeSegregation(r, depositKeys, depositNameIndex);
866
+ const cents = Math.abs(r.amount);
867
+ if (key === "") {
868
+ genericPool += cents;
869
+ } else {
870
+ coveredByParty.set(key, (coveredByParty.get(key) || 0) + cents);
871
+ }
872
+ }
873
+
874
+ // Apply each beneficiary's OWN coverage to that beneficiary's deposits first;
875
+ // any deposit still short then draws from the GENERIC residual pool (preserving
876
+ // the long-standing behavior for transfers that name no tenant). A deposit that
877
+ // remains short after both is flagged — attributed to the RIGHT beneficiary.
878
+ const stillShort = [];
879
+ for (const r of secDeposits) {
880
+ const key = partyKey(r.party);
881
+ let need = r.amount;
882
+ const own = coveredByParty.get(key) || 0;
883
+ const fromOwn = Math.min(own, need);
884
+ coveredByParty.set(key, own - fromOwn);
885
+ need -= fromOwn;
886
+ if (need > 0) stillShort.push({ rec: r, need });
887
+ }
888
+ // Generic pool covers whatever remains, in the same deterministic order.
889
+ for (const { rec: r, need } of stillShort) {
890
+ if (genericPool >= need) {
891
+ genericPool -= need;
892
+ continue;
893
+ }
894
+ // The genuinely-UNCOVERED amount is what the generic residual pool could not
895
+ // cover (`need` minus whatever this draw consumed) — never the full deposit
896
+ // when the pool partially covered it. This is the number at risk in trust.
897
+ const uncovered = need - genericPool;
898
+ genericPool = 0; // a partial generic draw is consumed; the rest is a finding
899
+ // NAME the at-risk beneficiary so the finding (and the report row) says WHO is
900
+ // exposed, not just that "a" deposit is un-segregated. A deposit with no party
901
+ // attributed falls back to an explicit "(unattributed beneficiary)" sentinel
902
+ // rather than an empty string, so the sentence never reads as a dangling name.
903
+ const who = beneficiaryLabel(r.party);
904
+ pushException(exceptions, {
905
+ type: EXCEPTION.SECURITY_DEPOSIT_SEGREGATION,
906
+ amount: r.amount,
907
+ label: "Security deposit not segregated",
908
+ detail:
909
+ `Security deposit for ${who} is not segregated: ${fmtCentsForDetail(uncovered)} ` +
910
+ "of this receipt has no matching transfer to a segregated deposit account. " +
911
+ "Many states require each beneficiary's security deposit be held separately " +
912
+ "from operating trust funds; transfer the uncovered amount to a segregated " +
913
+ `account attributed to ${who}.`,
914
+ records: [r],
915
+ });
916
+ }
917
+ }
918
+
919
+ // A human-readable beneficiary label for a finding sentence. Uses the record's
920
+ // raw party string verbatim when present (so "Jones (4B)" reads naturally), and a
921
+ // loud, explicit sentinel when the deposit names no beneficiary — never an empty
922
+ // string that would leave the sentence dangling. PURE.
923
+ function beneficiaryLabel(party) {
924
+ const p = String(party == null ? "" : party).trim();
925
+ return p === "" ? "an unattributed beneficiary" : p;
926
+ }
927
+
928
+ // Format integer cents as a signed dollar string ("$1,234.56") for embedding in a
929
+ // finding's detail sentence. reconcile.js is the pure core and intentionally does
930
+ // NOT depend on report.js, so this mirrors report.fmtCents's grouping locally with
931
+ // no new dependency. Deterministic; throws on non-integer input (no float money).
932
+ function fmtCentsForDetail(cents) {
933
+ if (!Number.isInteger(cents)) {
934
+ throw new ReconcileError("fmtCentsForDetail requires integer cents");
935
+ }
936
+ const neg = cents < 0;
937
+ const abs = Math.abs(cents);
938
+ const dollars = Math.floor(abs / 100);
939
+ const rem = abs % 100;
940
+ const grouped = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
941
+ const body = `$${grouped}.${String(rem).padStart(2, "0")}`;
942
+ return neg ? `-${body}` : body;
943
+ }
944
+
945
+ // The CLOSED set of control / sink account designations whose negative balance
946
+ // is STRUCTURAL, not a tenant shortage. The pooled trust account holds
947
+ // beneficiaries' money, but the per-party sub-ledger map also carries a few
948
+ // non-beneficiary control accounts whose balance is legitimately negative:
949
+ // * the OWNER's-own-funds line — an owner funds the account and DRAWS against
950
+ // their OWN money, so a negative owner position is expected (it is the
951
+ // owner's capital being deployed, not a tenant's trust money vanishing); and
952
+ // * an ESCROW / SEGREGATED / TRUST sink, or an OPERATING / RESERVE / SUSPENSE
953
+ // control line, which RECEIVES the offsetting outflow (the money that left
954
+ // the pooled side to be held separately), so it nets negative by design.
955
+ const CONTROL_ACCOUNT_TOKENS = Object.freeze([
956
+ "owner",
957
+ "owners",
958
+ "escrow",
959
+ "segregated",
960
+ "trust",
961
+ "operating",
962
+ "reserve",
963
+ "suspense",
964
+ ]);
965
+
966
+ // Does a free-text party name READ as a control-account DESIGNATION? A negative
967
+ // balance on a control/sink account is structural; a negative balance on any
968
+ // OTHER party is a real beneficiary whose trust money is gone, so this is the
969
+ // ONLY thing that suppresses a negative_tenant_ledger finding when the caller
970
+ // gave us no structured marker.
971
+ //
972
+ // NARROWLY ANCHORED to the LEADING token (the account-designation position):
973
+ // only when the FIRST whole-word token of the name is a control word
974
+ // ("Owner ...", "Escrow ...", "Reserve ...") do we treat the line as a control
975
+ // account. The previous rule matched a control token ANYWHERE in the name, which
976
+ // silently OVER-EXCLUDED real beneficiaries whose name merely contains a control
977
+ // word — "Smith (OWNER)", "Jones Family Trust", "Tenant 12 Reserve St" — and so
978
+ // swallowed exactly the shortage T-41.1 exists to surface. The leading-token
979
+ // anchor keeps the genuine designations ("Owner Acme", "Escrow") excluded while
980
+ // no longer dropping a beneficiary whose unit/address/entity name happens to
981
+ // carry a control token in a NON-leading position.
982
+ //
983
+ // LIMITATION (documented in docs/TRUSTLEDGER.md): a name heuristic cannot tell a
984
+ // real company named "Operating Co LLC" from an "Operating" control account, so a
985
+ // leading control word is still treated as a designation. A real beneficiary
986
+ // whose name LEADS with a control word — and any control account the broker wants
987
+ // recognized unambiguously — must carry the STRUCTURED `controlAccount` marker,
988
+ // which is authoritative over this guess (see controlAccountKeys / the
989
+ // per-row/per-balance marker). Still WORD-BOUNDED so an ordinary surname that
990
+ // merely CONTAINS one of these tokens ("Owens", "Crowell") is never mistaken for
991
+ // a control account. PURE.
992
+ function isControlAccountParty(party) {
993
+ const tokens = nameTokens(party);
994
+ if (tokens.length === 0) return false;
995
+ return CONTROL_ACCOUNT_TOKENS.includes(tokens[0]);
996
+ }
997
+
998
+ // Mirror the EXACT per-party normalization tenantBalances uses for the array
999
+ // (rent-roll) form, so a control-account marker keys to the SAME sub-ledger
1000
+ // bucket the balance lives under. PURE.
1001
+ function normTenantParty(party) {
1002
+ return String(party == null ? "unknown" : party).trim() || "unknown";
1003
+ }
1004
+
1005
+ // The set of sub-ledger party keys the CALLER has STRUCTURALLY asserted are
1006
+ // control / sink accounts (not beneficiaries), via an explicit `controlAccount:
1007
+ // true` marker. This is a deliberate, structured assertion by the producer of
1008
+ // the data — distinct from us GUESSING from the free-text name — and is therefore
1009
+ // AUTHORITATIVE: a marked party is excluded from the negative-ledger finding no
1010
+ // matter what its name reads like, and (because the marker only ever ADDS to the
1011
+ // exclusion set, never removes the name heuristic) it can only make the check
1012
+ // MORE permissive about structural negatives, never flag fewer real shortages
1013
+ // than the name heuristic alone would. Mirrors how hasExplicitDepositLabel
1014
+ // prefers a structured assertion over a free-text guess.
1015
+ //
1016
+ // The marker is honored on the ARRAY (rent-roll) form, where each row may carry
1017
+ // `controlAccount: true`; ANY such row marks its party's bucket. The precomputed
1018
+ // `{ party: cents }` map form has no per-key slot for a marker, so a bare map
1019
+ // falls back to the name heuristic alone (a caller who needs to mark a control
1020
+ // account in the map form should supply rows or rely on the leading-token name).
1021
+ // Returns a Set of normalized party keys. PURE + order-independent.
1022
+ function controlAccountKeys(tenants) {
1023
+ const keys = new Set();
1024
+ if (!Array.isArray(tenants)) return keys;
1025
+ for (const r of tenants) {
1026
+ if (r && r.controlAccount === true) {
1027
+ keys.add(normTenantParty(r.party));
1028
+ }
1029
+ }
1030
+ return keys;
1031
+ }
1032
+
1033
+ // Per-beneficiary negative-ledger check (T-41.1). Flag EACH individual
1034
+ // beneficiary whose own sub-ledger balance is negative beyond tolerance — i.e.
1035
+ // the broker is holding LESS than zero in trust for that person, because their
1036
+ // money was spent or used to cover another beneficiary's shortfall. This is
1037
+ // ORTHOGONAL to the pooled SUBLEDGER_OUT_OF_BALANCE check: the SUM of all
1038
+ // sub-ledgers can tie perfectly to the book (one tenant's surplus masking
1039
+ // another's deficit) while an individual ledger is negative, so this check fires
1040
+ // independently of the SUM. A control/sink account is NOT a beneficiary and is
1041
+ // excluded — its negative balance is structural, not a shortage. A control
1042
+ // account is recognized by EITHER an authoritative STRUCTURED `controlAccount`
1043
+ // marker (`controlKeys`, preferred) OR, absent a marker, a NARROWLY-ANCHORED
1044
+ // leading-token name heuristic (owner/escrow/segregated/trust/operating/reserve/
1045
+ // suspense in the FIRST name token) — see isControlAccountParty for why the
1046
+ // heuristic is anchored to the leading token and its documented limitation.
1047
+ // `toleranceCents` is honored: a balance is flagged only when it is below
1048
+ // `-toleranceCents`, so a caller's deliberate penny-tolerance applies the SAME
1049
+ // way it does to the SUM checks. Deterministic + order-independent: beneficiaries
1050
+ // are flagged in a stable key-sorted order. This can only ADD findings, never
1051
+ // remove one (strictly non-looser). PURE.
1052
+ function classifyNegativeTenantLedgers(subBalances, toleranceCents, exceptions, controlKeys) {
1053
+ const tol = Number.isInteger(toleranceCents) && toleranceCents >= 0 ? toleranceCents : 0;
1054
+ const control = controlKeys instanceof Set ? controlKeys : new Set();
1055
+ const parties = Object.keys(subBalances).sort(cmp);
1056
+ for (const party of parties) {
1057
+ const bal = subBalances[party];
1058
+ if (!Number.isInteger(bal)) continue; // tenantBalances already validated; guard anyway
1059
+ if (bal >= -tol) continue; // zero/positive (or within tolerance) is fine
1060
+ // A structured marker is authoritative; absent one, fall back to the
1061
+ // narrowly-anchored leading-token name heuristic.
1062
+ if (control.has(party) || isControlAccountParty(party)) continue; // structural negative, not a shortage
1063
+ const who = beneficiaryLabel(party);
1064
+ pushException(exceptions, {
1065
+ type: EXCEPTION.NEGATIVE_TENANT_LEDGER,
1066
+ amount: bal, // the negative balance itself (signed), so the row says how short
1067
+ label: "Beneficiary ledger is negative",
1068
+ detail:
1069
+ `The individual trust ledger for ${who} is negative (${fmtCentsForDetail(bal)}): ` +
1070
+ "the broker is holding less than zero in trust for this beneficiary, so " +
1071
+ "their money has been spent or used to cover another beneficiary's " +
1072
+ "shortfall. A negative individual ledger is out of trust even when the " +
1073
+ "pooled sum of all sub-ledgers still ties to the book; restore the " +
1074
+ "beneficiary's balance to at least zero before relying on this packet.",
1075
+ records: [],
1076
+ });
1077
+ }
1078
+ }
1079
+
1080
+ // Ambiguous deposits: every book INFLOW that calls itself a deposit but whose
1081
+ // beneficiary type we cannot determine (no recognized purpose keyword, not an
1082
+ // explicitly-labeled rent/receipt) becomes a LOUD, gradable WARNING finding
1083
+ // rather than passing silently as a generic deposit. A record that already
1084
+ // surfaced as a SECURITY_DEPOSIT_SEGREGATION finding is NOT re-flagged here:
1085
+ // isAmbiguousDeposit already excludes anything isSecurityDeposit recognizes, so
1086
+ // there is no double-count of the same row across the two findings. PURE +
1087
+ // deterministic: order-independent (sorted by record key) free-text classification.
1088
+ function classifyAmbiguousDeposits(book, exceptions) {
1089
+ for (const r of [...book]
1090
+ .filter(isAmbiguousDeposit)
1091
+ .sort((x, y) => cmp(recKey(x), recKey(y)))) {
1092
+ pushException(exceptions, {
1093
+ type: EXCEPTION.AMBIGUOUS_DEPOSIT,
1094
+ amount: r.amount,
1095
+ label: "Ambiguous deposit (beneficiary type undetermined)",
1096
+ detail:
1097
+ "A book deposit with no recognizable beneficiary-type keyword (it is " +
1098
+ "not clearly rent, an owner contribution, or a labeled security " +
1099
+ "deposit). Confirm what it is: an un-segregated security deposit hiding " +
1100
+ "as a generic deposit is an out-of-trust finding. Label the row " +
1101
+ "(e.g. kind \"rent\", a security-deposit memo, or an explicit deposit " +
1102
+ "type) so this resolves.",
1103
+ records: [r],
1104
+ });
1105
+ }
1106
+ }
1107
+
1108
+ // ---------------------------------------------------------------------------
1109
+ // Triage (T-43.1): classify every finding by ROOT-CAUSE CLASS, roll it up by
1110
+ // dollar impact, and name the single most-important thing to fix.
1111
+ // ---------------------------------------------------------------------------
1112
+ //
1113
+ // A FAIL verdict today is a COUNT, not a cause: "N exception(s) [X error, Y
1114
+ // warning, Z info]". A broker reading that cannot tell the make-or-break thing
1115
+ // at first contact — is the trust account GENUINELY OUT OF TRUST (the product
1116
+ // delivering its core value), or did the TOOL simply fail to reconcile/classify
1117
+ // THEIR DATA (a data-shape gap to fix and re-run)? `triage` answers exactly that
1118
+ // question. It is PURE, DETERMINISTIC, ORDER-INDEPENDENT, mutates nothing, and
1119
+ // performs NO I/O — the same property the rest of this core has, for the same
1120
+ // reason: a diagnosis a broker acts on and an auditor reads must be reproducible.
1121
+ //
1122
+ // The FOUR root-cause classes (named in STRATEGY.md "## Direction", EPIC-43):
1123
+ // * out_of_trust — a real shortage/commingling/conversion. The trust
1124
+ // account is genuinely out of trust; the product's core
1125
+ // finding. This is what a pilot broker must read as
1126
+ // "fix the trust account", NOT "the tool is broken".
1127
+ // * data_completeness — the tool could not fully reconcile/classify the data:
1128
+ // an unmatched line, an undetermined deposit type, a
1129
+ // residual bank/book gap. A data-shape gap to fix and
1130
+ // re-run — NOT (yet) evidence the money is gone.
1131
+ // * needs_review — a real movement that may be legitimate but a human
1132
+ // must eyeball (an owner draw within capital, an NSF).
1133
+ // * timing — a benign, self-clearing reconciling item (a deposit
1134
+ // in transit, an outstanding check). Expected; explains
1135
+ // a gap rather than being a finding.
1136
+ //
1137
+ // `ROOT_CAUSE_CLASS` is a CLOSED enum and `CLASS_OF` maps EVERY `EXCEPTION` type
1138
+ // to exactly one class. An exhaustiveness guard runs AT LOAD TIME (below): if a
1139
+ // new EXCEPTION type is ever added without a class — or a class points at a name
1140
+ // not in ROOT_CAUSE_CLASS — the module throws on require, so an unclassified
1141
+ // finding is a BUILD error, never a silently-misrouted one at runtime.
1142
+
1143
+ const ROOT_CAUSE_CLASS = Object.freeze({
1144
+ OUT_OF_TRUST: "out_of_trust",
1145
+ DATA_COMPLETENESS: "data_completeness",
1146
+ NEEDS_REVIEW: "needs_review",
1147
+ TIMING: "timing",
1148
+ });
1149
+
1150
+ // The order a human reads the classes in: most-urgent first. Used as the stable
1151
+ // tie-break for the headline and to order the per-class roll-up array, so the
1152
+ // table always leads with the class that decides the verdict.
1153
+ const CLASS_RANK = Object.freeze(
1154
+ Object.assign(Object.create(null), {
1155
+ [ROOT_CAUSE_CLASS.OUT_OF_TRUST]: 0,
1156
+ [ROOT_CAUSE_CLASS.DATA_COMPLETENESS]: 1,
1157
+ [ROOT_CAUSE_CLASS.NEEDS_REVIEW]: 2,
1158
+ [ROOT_CAUSE_CLASS.TIMING]: 3,
1159
+ })
1160
+ );
1161
+
1162
+ // Every EXCEPTION type -> its root-cause class. EXHAUSTIVE by construction (the
1163
+ // load-time guard below proves it). The rationale for each non-obvious mapping:
1164
+ // * BANK_BOOK_MISMATCH is DIRECTIONAL — its class is NOT a static entry here
1165
+ // but is decided per-exception by classOfException() (below) from the SIGN of
1166
+ // the residual gap (amount = adjustedBank - book):
1167
+ // - amount < 0 (beyond tolerance): the bank holds LESS cash than the books
1168
+ // say it should — a genuine shortage, the textbook out-of-trust case (the
1169
+ // money is not in the account). Routed to OUT_OF_TRUST.
1170
+ // - amount >= 0: the bank holds MORE than the books record — an UNRECORDED
1171
+ // DEPOSIT / posting omission to write down, the benign "fix this one item
1172
+ // and re-run" data tidy-up. Routed to DATA_COMPLETENESS.
1173
+ // A single static CLASS_OF entry could not express this, and the bank-SHORT
1174
+ // direction routed to DATA_COMPLETENESS would emit a confidently-wrong,
1175
+ // reassuring "FIX YOUR DATA" headline over a real missing-cash shortage — so
1176
+ // BANK_BOOK_MISMATCH is deliberately absent from CLASS_OF and handled in
1177
+ // classOfException, which the load-time guard treats as a valid mapping.
1178
+ // * CONTINUITY_BREAK is OUT_OF_TRUST: a broken roll-forward means the chain of
1179
+ // custody over the trust money is broken — an out-of-trust-grade integrity
1180
+ // failure, not a mere data tidy-up.
1181
+ // * AMBIGUOUS_DEPOSIT is DATA_COMPLETENESS: the tool could not determine the
1182
+ // deposit's beneficiary type. It MIGHT hide an un-segregated security deposit
1183
+ // (which, once labeled, would surface as out_of_trust) — but as-is it is a
1184
+ // classification gap the broker resolves by labeling the row, so it belongs
1185
+ // with the other "fix-my-data" findings, not pre-judged as out of trust.
1186
+ //
1187
+ // NOTE on the table's PROTOTYPE: this is the ONE lookup table that takes an
1188
+ // untrusted key (ex.type, from a possibly hand-built/forged model). It is built
1189
+ // on a NULL prototype so that a forged `ex.type` of an Object.prototype member
1190
+ // name ("__proto__", "constructor", "hasOwnProperty", "toString", ...) resolves
1191
+ // to `undefined` (the rejected-unknown-type path) rather than inheriting a
1192
+ // garbage prototype value and bypassing the strict-rejection guard. CLASS_RANK /
1193
+ // CLASS_LABEL are keyed by our own ROOT_CAUSE_CLASS values (never untrusted
1194
+ // input) but are built the same way for consistency.
1195
+ const CLASS_OF = Object.freeze(
1196
+ Object.assign(Object.create(null), {
1197
+ [EXCEPTION.OUTSTANDING_DEPOSIT]: ROOT_CAUSE_CLASS.TIMING,
1198
+ [EXCEPTION.OUTSTANDING_CHECK]: ROOT_CAUSE_CLASS.TIMING,
1199
+ [EXCEPTION.TIMING]: ROOT_CAUSE_CLASS.TIMING,
1200
+ [EXCEPTION.NSF_REVERSAL]: ROOT_CAUSE_CLASS.NEEDS_REVIEW,
1201
+ [EXCEPTION.OWNER_DRAW]: ROOT_CAUSE_CLASS.NEEDS_REVIEW,
1202
+ [EXCEPTION.OWNER_OVERDRAW]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
1203
+ [EXCEPTION.SECURITY_DEPOSIT_SEGREGATION]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
1204
+ [EXCEPTION.AMBIGUOUS_DEPOSIT]: ROOT_CAUSE_CLASS.DATA_COMPLETENESS,
1205
+ [EXCEPTION.UNRECONCILED_BANK]: ROOT_CAUSE_CLASS.DATA_COMPLETENESS,
1206
+ [EXCEPTION.UNRECONCILED_BOOK]: ROOT_CAUSE_CLASS.DATA_COMPLETENESS,
1207
+ [EXCEPTION.SUBLEDGER_OUT_OF_BALANCE]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
1208
+ [EXCEPTION.NEGATIVE_TENANT_LEDGER]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
1209
+ [EXCEPTION.CONTINUITY_BREAK]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
1210
+ })
1211
+ );
1212
+
1213
+ // DIRECTIONAL exception types whose class depends on per-exception data (the sign
1214
+ // of the residual gap), NOT a static CLASS_OF entry. These are CLASSIFIED, just
1215
+ // not via the flat table — the load-time guard treats membership here as a valid
1216
+ // mapping so the closed-table discipline still holds (every EXCEPTION type is
1217
+ // EITHER in CLASS_OF OR here; never neither, never both).
1218
+ const DIRECTIONAL_TYPES = Object.freeze(
1219
+ Object.assign(Object.create(null), {
1220
+ [EXCEPTION.BANK_BOOK_MISMATCH]: true,
1221
+ })
1222
+ );
1223
+
1224
+ // Resolve the root-cause class of ONE exception. For a static type this is the
1225
+ // CLASS_OF entry; for a DIRECTIONAL type (BANK_BOOK_MISMATCH) it is decided from
1226
+ // the sign of amount = adjustedBank - book. Returns undefined for an unknown type
1227
+ // (the rejected-unknown-type path) — uses an own-property lookup so a forged
1228
+ // prototype-key type can never inherit a bogus class. PURE: a function of
1229
+ // (ex.type, ex.amount) only.
1230
+ function classOfException(ex) {
1231
+ const type = ex && ex.type;
1232
+ if (type === EXCEPTION.BANK_BOOK_MISMATCH) {
1233
+ // amount = adjustedBank - book. NEGATIVE => bank holds LESS than the books say
1234
+ // (cash is missing relative to the records) => a genuine out-of-trust
1235
+ // shortage. NON-NEGATIVE => bank holds MORE (an unrecorded deposit/posting
1236
+ // omission) => a benign data-completeness item to record and re-run. We read
1237
+ // ex.amount through the SAME integer-cents discipline exceptionImpact uses, so
1238
+ // a non-integer (float) amount is a hard error here too, never coerced to pick
1239
+ // a direction. Zero is impossible past tolerance (the finding would not fire),
1240
+ // but is classed DATA_COMPLETENESS for totality (a non-negative, non-short gap).
1241
+ const a = ex.amount;
1242
+ if (!Number.isInteger(a)) {
1243
+ throw new ReconcileError("triage: exception.amount must be integer cents");
1244
+ }
1245
+ return a < 0 ? ROOT_CAUSE_CLASS.OUT_OF_TRUST : ROOT_CAUSE_CLASS.DATA_COMPLETENESS;
1246
+ }
1247
+ // own-property lookup on the null-prototype table: an unknown / forged
1248
+ // prototype-key type resolves to undefined and is rejected by the caller.
1249
+ return CLASS_OF[type];
1250
+ }
1251
+
1252
+ // LOAD-TIME EXHAUSTIVENESS GUARD. Proves, on require, that:
1253
+ // 1. EVERY EXCEPTION type has a class (none falls through unclassified), and
1254
+ // 2. EVERY mapped class is a real ROOT_CAUSE_CLASS member (no typo'd target),
1255
+ // 3. EVERY ROOT_CAUSE_CLASS member has a CLASS_RANK (the read order is total).
1256
+ // Any violation is a BUILD error (thrown at module load), never a silent runtime
1257
+ // mis-route — the same closed-table discipline the entitlement table uses.
1258
+ (function assertTriageExhaustive() {
1259
+ const classValues = new Set(Object.values(ROOT_CAUSE_CLASS));
1260
+ for (const type of Object.values(EXCEPTION)) {
1261
+ const directional = DIRECTIONAL_TYPES[type] === true;
1262
+ const inTable = Object.prototype.hasOwnProperty.call(CLASS_OF, type);
1263
+ // EXACTLY-ONE: every EXCEPTION type is classified by EITHER the static table
1264
+ // OR the directional classifier, never neither (a fall-through) and never
1265
+ // both (an ambiguous mapping). Either is a BUILD error.
1266
+ if (!inTable && !directional) {
1267
+ throw new ReconcileError(
1268
+ `triage: EXCEPTION type "${type}" has no root-cause class (neither CLASS_OF nor a directional classifier covers it)`
1269
+ );
1270
+ }
1271
+ if (inTable && directional) {
1272
+ throw new ReconcileError(
1273
+ `triage: EXCEPTION type "${type}" is BOTH a static CLASS_OF entry and a directional type (ambiguous mapping)`
1274
+ );
1275
+ }
1276
+ if (inTable) {
1277
+ const cls = CLASS_OF[type];
1278
+ if (!classValues.has(cls)) {
1279
+ throw new ReconcileError(
1280
+ `triage: EXCEPTION type "${type}" maps to unknown class "${cls}"`
1281
+ );
1282
+ }
1283
+ } else {
1284
+ // A directional type must yield a real class for BOTH sign directions, so
1285
+ // neither direction can silently route to a bogus class.
1286
+ for (const probe of [-1, 1]) {
1287
+ const cls = classOfException({ type, amount: probe });
1288
+ if (!classValues.has(cls)) {
1289
+ throw new ReconcileError(
1290
+ `triage: directional EXCEPTION type "${type}" maps to unknown class "${cls}" for amount ${probe}`
1291
+ );
1292
+ }
1293
+ }
1294
+ }
1295
+ }
1296
+ for (const cls of classValues) {
1297
+ if (CLASS_RANK[cls] === undefined) {
1298
+ throw new ReconcileError(`triage: root-cause class "${cls}" has no CLASS_RANK`);
1299
+ }
1300
+ }
1301
+ })();
1302
+
1303
+ // A short, human caption + a one-line explanation per class, for the headline.
1304
+ const CLASS_LABEL = Object.freeze(
1305
+ Object.assign(Object.create(null), {
1306
+ [ROOT_CAUSE_CLASS.OUT_OF_TRUST]: "Out of trust",
1307
+ [ROOT_CAUSE_CLASS.DATA_COMPLETENESS]: "Fix the data",
1308
+ [ROOT_CAUSE_CLASS.NEEDS_REVIEW]: "Needs review",
1309
+ [ROOT_CAUSE_CLASS.TIMING]: "Timing",
1310
+ })
1311
+ );
1312
+
1313
+ // Abs-cents impact of one exception. Money figures are integer cents; a
1314
+ // non-integer amount is a hard error (the same no-float-money discipline the
1315
+ // rest of this core enforces) rather than being silently coerced. We sum the
1316
+ // ABSOLUTE value because impact is "how many dollars this finding touches",
1317
+ // independent of inflow/outflow sign — a -$500 negative ledger and a +$500
1318
+ // unreconciled deposit each represent $500 of exposure to weigh.
1319
+ function exceptionImpact(ex) {
1320
+ const a = ex && ex.amount;
1321
+ if (!Number.isInteger(a)) {
1322
+ throw new ReconcileError("triage: exception.amount must be integer cents");
1323
+ }
1324
+ return Math.abs(a);
1325
+ }
1326
+
1327
+ // triage(model) — classify the findings in a reconcile result OR a buildPacket
1328
+ // model by root cause, roll them up, and name the top thing to fix. Accepts
1329
+ // anything carrying an `exceptions` array of { type, severity, amount } (both
1330
+ // the raw reconcile() result and the report.buildPacket() model qualify), so it
1331
+ // is a pure read-only lens over the EXISTING classified findings — it consumes
1332
+ // the array, never re-derives or re-classifies the underlying records.
1333
+ //
1334
+ // Returns (a NEW object; `model` is never mutated):
1335
+ // {
1336
+ // classes: [ // one row per class that has >=1 finding,
1337
+ // { // in CLASS_RANK (most-urgent-first) order
1338
+ // class: <ROOT_CAUSE_CLASS>,
1339
+ // label: <short caption>,
1340
+ // count: <int>, // findings in this class
1341
+ // absImpact: <int cents>, // summed ABS-cents impact of the class
1342
+ // }, ...
1343
+ // ],
1344
+ // totals: { count, absImpact }, // across ALL findings
1345
+ // outOfTrust: <bool>, // is there >=1 out_of_trust finding?
1346
+ // dataIncomplete: <bool>, // is there >=1 data_completeness finding?
1347
+ // topClass: <ROOT_CAUSE_CLASS|null>, // the class to fix first
1348
+ // headline: <string>, // ONE unambiguous sentence: out-of-trust
1349
+ // // vs. fix-my-data vs. clean
1350
+ // }
1351
+ //
1352
+ // The `headline` is the make-or-break distinction: it says "OUT OF TRUST" only
1353
+ // when there is a genuine out_of_trust finding, says "fix your data and re-run"
1354
+ // when the only blockers are data_completeness gaps, and says the books are
1355
+ // clean when there is nothing in either bucket — so a pilot broker reads a FAIL
1356
+ // correctly at first contact instead of as "the tool is broken".
1357
+ function triage(model) {
1358
+ if (!model || !Array.isArray(model.exceptions)) {
1359
+ throw new ReconcileError("triage requires a model with an exceptions array");
1360
+ }
1361
+
1362
+ // Accumulate per class. Iterate the (unordered) exceptions and fold into a map
1363
+ // keyed by class — addition + a count is commutative, so the roll-up is
1364
+ // order-independent regardless of how the exceptions array is sorted.
1365
+ const byClass = new Map(); // class -> { count, absImpact }
1366
+ let totalCount = 0;
1367
+ let totalImpact = 0;
1368
+ for (const ex of model.exceptions) {
1369
+ // classOfException resolves the class via the static null-prototype CLASS_OF
1370
+ // table (own-property lookup) OR the directional classifier (BANK_BOOK_MISMATCH
1371
+ // by sign). A forged prototype-key type ("__proto__", "constructor", ...)
1372
+ // resolves to undefined here and is rejected below, never inheriting a bogus
1373
+ // class and bypassing the guard.
1374
+ const cls = classOfException(ex);
1375
+ if (cls === undefined) {
1376
+ // An exception of an unknown type would be silently dropped from the
1377
+ // roll-up — exactly the kind of silent miscount this module exists to
1378
+ // prevent. Fail loud instead. (The load-time guard makes this unreachable
1379
+ // for the built-in EXCEPTION set; it defends a hand-built/forged model.)
1380
+ throw new ReconcileError(
1381
+ `triage: exception of unknown type "${ex.type}" cannot be classified`
1382
+ );
1383
+ }
1384
+ const impact = exceptionImpact(ex);
1385
+ const agg = byClass.get(cls) || { count: 0, absImpact: 0 };
1386
+ agg.count += 1;
1387
+ agg.absImpact += impact;
1388
+ byClass.set(cls, agg);
1389
+ totalCount += 1;
1390
+ totalImpact += impact;
1391
+ }
1392
+
1393
+ // Emit the per-class rows in CLASS_RANK order (most-urgent first), only for
1394
+ // classes that actually have a finding. Deterministic + order-independent.
1395
+ const classes = [...byClass.keys()]
1396
+ .sort((a, b) => CLASS_RANK[a] - CLASS_RANK[b])
1397
+ .map((cls) => ({
1398
+ class: cls,
1399
+ label: CLASS_LABEL[cls],
1400
+ count: byClass.get(cls).count,
1401
+ absImpact: byClass.get(cls).absImpact,
1402
+ }));
1403
+
1404
+ const outOfTrust = byClass.has(ROOT_CAUSE_CLASS.OUT_OF_TRUST);
1405
+ const dataIncomplete = byClass.has(ROOT_CAUSE_CLASS.DATA_COMPLETENESS);
1406
+
1407
+ // The top class to fix first = the present class with the lowest CLASS_RANK
1408
+ // (out_of_trust before data_completeness before needs_review before timing).
1409
+ // null when there are no findings at all. `classes` is already rank-sorted, so
1410
+ // the first row is the top class.
1411
+ const topClass = classes.length > 0 ? classes[0].class : null;
1412
+
1413
+ return {
1414
+ classes,
1415
+ totals: { count: totalCount, absImpact: totalImpact },
1416
+ outOfTrust,
1417
+ dataIncomplete,
1418
+ topClass,
1419
+ headline: buildHeadline(byClass, outOfTrust, dataIncomplete),
1420
+ };
1421
+ }
1422
+
1423
+ // Build the ONE unambiguous headline sentence. PURE. The distinction the pilot
1424
+ // turns on is out_of_trust vs. fix-my-data, so the sentence LEADS with whichever
1425
+ // applies and never blurs the two:
1426
+ // * ANY out_of_trust finding => "OUT OF TRUST" leads (the core product
1427
+ // verdict), even if data-completeness gaps also exist — a genuine shortage
1428
+ // is never softened into a mere data note.
1429
+ // * else ANY data_completeness => "the tool could not fully reconcile your
1430
+ // data" — a fixable data-shape gap, explicitly NOT an out-of-trust claim.
1431
+ // * else (only needs_review / timing, or nothing) => the account is NOT shown
1432
+ // out of trust; remaining items are review/timing notes.
1433
+ function buildHeadline(byClass, outOfTrust, dataIncomplete) {
1434
+ if (outOfTrust) {
1435
+ const c = byClass.get(ROOT_CAUSE_CLASS.OUT_OF_TRUST);
1436
+ const also = dataIncomplete
1437
+ ? " There are also data-completeness gaps to fix, but the out-of-trust finding is the priority."
1438
+ : "";
1439
+ return (
1440
+ `OUT OF TRUST: ${countNoun(c.count, "finding")} totaling ${fmtCentsForDetail(c.absImpact)} ` +
1441
+ `show the trust account is genuinely out of trust. Restore the trust account before relying on this packet.` +
1442
+ also
1443
+ );
1444
+ }
1445
+ if (dataIncomplete) {
1446
+ const c = byClass.get(ROOT_CAUSE_CLASS.DATA_COMPLETENESS);
1447
+ return (
1448
+ `FIX YOUR DATA: the trust account is NOT shown out of trust — the tool could not fully reconcile ` +
1449
+ `your data (${countNoun(c.count, "item")} totaling ${fmtCentsForDetail(c.absImpact)}). ` +
1450
+ `Resolve these data gaps and re-run; this is not (yet) evidence the money is gone.`
1451
+ );
1452
+ }
1453
+ const review = byClass.get(ROOT_CAUSE_CLASS.NEEDS_REVIEW);
1454
+ const timing = byClass.get(ROOT_CAUSE_CLASS.TIMING);
1455
+ if (review || timing) {
1456
+ return (
1457
+ `NO OUT-OF-TRUST FINDING: the trust account is not shown out of trust and the data reconciled. ` +
1458
+ `${countNoun((review ? review.count : 0) + (timing ? timing.count : 0), "item")} remain as ` +
1459
+ `review/timing notes for a human to confirm.`
1460
+ );
1461
+ }
1462
+ return "NO FINDINGS: every line reconciled and nothing is out of trust.";
1463
+ }
1464
+
1465
+ // "1 finding" / "2 findings" — a count noun that pluralizes deterministically.
1466
+ function countNoun(n, noun) {
1467
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
1468
+ }
1469
+
1470
+ module.exports = {
1471
+ reconcile,
1472
+ ReconcileError,
1473
+ EXCEPTION,
1474
+ SEVERITY,
1475
+ DEFAULT_SEVERITY,
1476
+ // exported for focused tests / reuse
1477
+ tenantBalances,
1478
+ compareExceptions,
1479
+ buildContinuityException,
1480
+ isAmbiguousDeposit,
1481
+ // T-43.1 triage
1482
+ triage,
1483
+ ROOT_CAUSE_CLASS,
1484
+ CLASS_OF,
1485
+ classOfException,
1486
+ };