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,887 @@
1
+ "use strict";
2
+
3
+ // TrustLedger — report.js
4
+ //
5
+ // T-22.4: the audit-ready reconciliation PACKET.
6
+ //
7
+ // This is the demoable core value of the whole product: a broker hands the tool
8
+ // their three real monthly files and watches the three numbers that must legally
9
+ // agree tie out (or not), then files the dated packet this module emits as the
10
+ // evidence of the reconciliation.
11
+ //
12
+ // This module is the PRESENTATION layer over the deterministic pipeline:
13
+ //
14
+ // ingest.js -> parse the three files into NormalizedRecord[]
15
+ // match.js -> pair bank<->book lines (exact + fuzzy + split)
16
+ // reconcile.js -> the THREE-balance check + classified exception list
17
+ // report.js -> render it all into a dated, audit-ready packet
18
+ //
19
+ // We emit the packet as **HTML + CSV**:
20
+ // * HTML — a single self-contained, print-to-PDF-ready document a broker can
21
+ // open in any browser and "Print -> Save as PDF" to file with their
22
+ // records. No binary PDF/xlsx library is pulled in (those heavy deps
23
+ // are explicitly deferred to v2): HTML prints to PDF and CSV opens in
24
+ // any spreadsheet, with zero new dependencies and zero install risk.
25
+ // * CSV — the exception list as a spreadsheet, so a bookkeeper can work the
26
+ // findings line by line.
27
+ //
28
+ // DETERMINISTIC. Given the same inputs (and an explicit report date) this module
29
+ // returns byte-identical output. It takes NO clock of its own — the caller MUST
30
+ // pass `reportDate` (a "YYYY-MM-DD" string). A reconciliation a broker signs and
31
+ // an auditor reads must be reproducible to the byte; a hidden `new Date()` would
32
+ // make the same inputs produce a different file every run, which is the opposite
33
+ // of audit-defensible. The CLI passes today's date explicitly so the human sees
34
+ // a dated packet while the function itself stays pure.
35
+ //
36
+ // HONEST POSTURE: the packet leads with a prominent disclaimer that the tool
37
+ // AIDS reconciliation but the broker remains the responsible custodian. It does
38
+ // not, and cannot, replace the broker's legal duty or a CPA's review.
39
+
40
+ const ingest = require("./ingest");
41
+ const match = require("./match");
42
+ const reconcile = require("./reconcile");
43
+ const policyMod = require("./policy");
44
+ const close = require("./close");
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Errors
48
+ // ---------------------------------------------------------------------------
49
+
50
+ class ReportError extends Error {
51
+ constructor(message) {
52
+ super(message);
53
+ this.name = "ReportError";
54
+ }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Money formatting (integer cents -> "$1,234.56", signed)
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function fmtCents(cents) {
62
+ if (!Number.isInteger(cents)) {
63
+ throw new ReportError("fmtCents requires integer cents");
64
+ }
65
+ const neg = cents < 0;
66
+ const abs = Math.abs(cents);
67
+ const dollars = Math.floor(abs / 100);
68
+ const rem = abs % 100;
69
+ // Group the integer dollars with commas, deterministically.
70
+ const grouped = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
71
+ const body = `$${grouped}.${String(rem).padStart(2, "0")}`;
72
+ return neg ? `-${body}` : body;
73
+ }
74
+
75
+ // HTML-escape a string for safe interpolation into the document body.
76
+ function esc(s) {
77
+ return String(s == null ? "" : s)
78
+ .replace(/&/g, "&amp;")
79
+ .replace(/</g, "&lt;")
80
+ .replace(/>/g, "&gt;")
81
+ .replace(/"/g, "&quot;")
82
+ .replace(/'/g, "&#39;");
83
+ }
84
+
85
+ // CSV-escape one field per RFC-4180: wrap in quotes and double any quote when
86
+ // the field contains a comma, quote, CR, or LF. Deterministic.
87
+ function csvField(s) {
88
+ const v = String(s == null ? "" : s);
89
+ if (/[",\r\n]/.test(v)) {
90
+ return `"${v.replace(/"/g, '""')}"`;
91
+ }
92
+ return v;
93
+ }
94
+
95
+ // A short, human label for an amount sign direction.
96
+ function direction(cents) {
97
+ if (cents > 0) return "in";
98
+ if (cents < 0) return "out";
99
+ return "zero";
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // The disclaimer (single source of truth, reused in HTML + CSV).
104
+ // ---------------------------------------------------------------------------
105
+
106
+ const DISCLAIMER_LINES = Object.freeze([
107
+ "This reconciliation packet is a TOOL THAT AIDS reconciliation. The broker " +
108
+ "remains the legal trust-account custodian and is solely responsible for " +
109
+ "the accuracy and completeness of the trust-account records and for " +
110
+ "compliance with all applicable state trust-fund rules.",
111
+ "TrustLedger reconciles the files it is given; it cannot see transactions " +
112
+ "absent from those files, cannot judge whether a transaction is itself " +
113
+ "proper, and does not constitute legal, accounting, or audit advice.",
114
+ "Review every exception below and have a qualified CPA or your state " +
115
+ "regulator review this packet before relying on it.",
116
+ ]);
117
+
118
+ const DISCLAIMER_TEXT = DISCLAIMER_LINES.join(" ");
119
+
120
+ // An EXTRA disclaimer sentence appended ONLY when a per-state policy governed the
121
+ // run. It states plainly that the PASS/FAIL verdict reflects the SELECTED policy's
122
+ // reviewed severities — and that the policy itself is still NOT legal advice and
123
+ // must be reviewed by a CPA/counsel (cross-references the P-5 #1/#2 disclaimers).
124
+ // Templated with the governing policy's state label so the packet names WHAT
125
+ // governed it.
126
+ function policyDisclaimerLine(stateLabel) {
127
+ return (
128
+ `This run's PASS/FAIL verdict reflects the SELECTED trust-rule policy ` +
129
+ `"${stateLabel}", which overrides the built-in baseline severities. ` +
130
+ "Selecting a policy does NOT make this packet legal advice: the policy " +
131
+ "itself — its per-state severities and statute citations — is a DRAFT that " +
132
+ "a qualified CPA or legal counsel must review and adopt for your " +
133
+ "jurisdiction before you rely on this verdict."
134
+ );
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Build the deterministic packet MODEL from the three normalized record sets.
139
+ //
140
+ // This is the pure heart: ingest is done by the caller (so the caller controls
141
+ // file I/O), and this runs match + reconcile and assembles every number/row the
142
+ // renderers print. Returned model is JSON-serializable and order-stable.
143
+ // ---------------------------------------------------------------------------
144
+
145
+ // `priorClose` (optional): a validated period-close artifact (close.js) this run
146
+ // chains FORWARD from. When present, buildPacket runs close.checkContinuity over
147
+ // the prior ending vs. THIS period's opening and, on a non-zero gap, raises a
148
+ // CONTINUITY_BREAK exception that flows through the SAME applyPolicy / counts /
149
+ // verdict / report path as every other exception (and is policy-overridable).
150
+ // `emitClosePath` (optional): the caller-named path the emitted close was/will be
151
+ // written to, referenced in the output so the artifact is traceable. Neither is
152
+ // supplied unless `--prior-close`/`--emit-close` is used; without `priorClose` no
153
+ // continuity exception is ever raised and the model is byte-for-byte unchanged.
154
+ function buildPacket({
155
+ bank,
156
+ book,
157
+ rentroll,
158
+ reportDate,
159
+ period,
160
+ opening,
161
+ toleranceCents,
162
+ policy,
163
+ priorClose,
164
+ emitClosePath,
165
+ }) {
166
+ if (!Array.isArray(bank)) throw new ReportError("bank must be a NormalizedRecord[]");
167
+ if (!Array.isArray(book)) throw new ReportError("book must be a NormalizedRecord[]");
168
+ if (!Array.isArray(rentroll)) throw new ReportError("rentroll must be a NormalizedRecord[]");
169
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(reportDate || ""))) {
170
+ throw new ReportError('reportDate must be a "YYYY-MM-DD" string');
171
+ }
172
+
173
+ // 1) Pair bank<->book lines.
174
+ const matchResult = match.reconcile(bank, book);
175
+
176
+ // Resolve the reconcile tolerance. A per-state policy MAY declare its own
177
+ // `toleranceCents` (some statutes permit a de-minimis rounding band; most
178
+ // require an exact tie, i.e. 0). PRECEDENCE: a policy's toleranceCents, when
179
+ // present, OVERRIDES the CLI/default tolerance — the broker's reviewed
180
+ // jurisdiction rule, not an ad-hoc command-line value, governs how close the
181
+ // three balances must come. This matters because the tolerance feeds tiesOut
182
+ // and the bank/book + book/sub mismatch exceptions, which in turn drive the
183
+ // PASS/FAIL verdict; computing them under a tolerance DIFFERENT from the
184
+ // policy the packet names would be a silent discrepancy. Without a policy
185
+ // toleranceCents the CLI/default value is used unchanged (byte-for-byte the
186
+ // prior behaviour).
187
+ const cliTolerance = Number.isInteger(toleranceCents) ? toleranceCents : 0;
188
+ const effectiveTolerance =
189
+ policy && Number.isInteger(policy.toleranceCents)
190
+ ? policy.toleranceCents
191
+ : cliTolerance;
192
+
193
+ // Normalize the opening this run actually used (integer cents both legs), so it
194
+ // is the SAME value fed to reconcile AND checked for continuity against the
195
+ // prior close — no chance of checking a different opening than was computed on.
196
+ const usedOpening = {
197
+ bank: opening && Number.isInteger(opening.bank) ? opening.bank : 0,
198
+ book: opening && Number.isInteger(opening.book) ? opening.book : 0,
199
+ };
200
+
201
+ // 2) The three-balance check + classified exceptions.
202
+ const rawRec = reconcile.reconcile(bank, book, rentroll, {
203
+ matchResult,
204
+ opening: usedOpening,
205
+ toleranceCents: effectiveTolerance,
206
+ });
207
+
208
+ // 2a) Roll-forward continuity. When this run chains from a prior period's close,
209
+ // CHECK that this period's opening equals the prior period's signed ending,
210
+ // penny-exact. A non-zero gap is a CONTINUITY_BREAK exception INJECTED into
211
+ // the reconcile result BEFORE the policy is applied, so it flows through the
212
+ // SAME severity override, counts, verdict, exit code, and report path as
213
+ // every native exception. Without a priorClose this is skipped entirely and
214
+ // the result is byte-for-byte unchanged. The prior close is a validated
215
+ // close artifact (the CLI reads it via close.readClose); we re-validate here
216
+ // so buildPacket is safe to call directly.
217
+ let continuity = null;
218
+ let priorMeta = null;
219
+ if (priorClose != null) {
220
+ const prior = close.readClose(priorClose);
221
+ priorMeta = {
222
+ period: prior.period,
223
+ reportDate: prior.reportDate,
224
+ ending: { bank: prior.ending.bank, book: prior.ending.book },
225
+ };
226
+ const cont = close.checkContinuity(prior, usedOpening);
227
+ continuity = {
228
+ checked: true,
229
+ ok: cont.ok === true,
230
+ bankGap: Number.isInteger(cont.bankGap) ? cont.bankGap : 0,
231
+ bookGap: Number.isInteger(cont.bookGap) ? cont.bookGap : 0,
232
+ priorPeriod: prior.period,
233
+ priorReportDate: prior.reportDate,
234
+ priorEnding: { bank: prior.ending.bank, book: prior.ending.book },
235
+ thisOpening: { bank: usedOpening.bank, book: usedOpening.book },
236
+ };
237
+ const contEx = reconcile.buildContinuityException(cont, prior.period);
238
+ if (contEx) {
239
+ rawRec.exceptions.push(contEx);
240
+ // Re-sort so the freshly-injected exception lands in the canonical
241
+ // severity-first order, exactly as a natively-detected one would. (When a
242
+ // policy is present, applyPolicy re-sorts again under the overridden
243
+ // severities; this keeps the no-policy path correctly ordered too.)
244
+ rawRec.exceptions.sort(reconcile.compareExceptions);
245
+ }
246
+ }
247
+
248
+ // 2b) Apply the reviewed per-state policy (if any) over the reconcile result
249
+ // BEFORE anything reads severities. applyPolicy is PURE and, with no
250
+ // policy, returns the SAME object reference — so the no-policy path is
251
+ // byte-for-byte the built-in baseline. With a policy, the overridden
252
+ // severities (and citations) flow into the counts, the PASS/FAIL verdict,
253
+ // the exit code, and the rendered report uniformly.
254
+ const rec = policyMod.applyPolicy(rawRec, policy || null);
255
+ const policyMeta = policy
256
+ ? {
257
+ state: policy.state,
258
+ // The reconcile tolerance this policy imposed, when it declared one (else
259
+ // null — the CLI/default tolerance governed). Surfaced so the packet
260
+ // names the actual band the verdict was computed under, never implying a
261
+ // stricter/looser tie than was applied.
262
+ toleranceCents: Number.isInteger(policy.toleranceCents)
263
+ ? policy.toleranceCents
264
+ : null,
265
+ // Surface only the overrides that actually changed a present exception,
266
+ // each with its citation when the policy supplies one, so the report can
267
+ // ground the verdict in the rule it rests on.
268
+ overrides: Object.keys(policy.severities)
269
+ .sort()
270
+ .map((type) => ({
271
+ type,
272
+ severity: policy.severities[type],
273
+ citation:
274
+ policy.citations && policy.citations[type] != null
275
+ ? policy.citations[type]
276
+ : null,
277
+ })),
278
+ }
279
+ : null;
280
+
281
+ // 3) Per-beneficiary sub-ledger balances (sorted by party for stable output).
282
+ const subBalances = reconcile.tenantBalances(rentroll);
283
+ const beneficiaries = Object.keys(subBalances)
284
+ .sort()
285
+ .map((party) => ({ party, balance: subBalances[party] }));
286
+
287
+ // 4) Exception rows flattened for the CSV/table (records summarized, not raw).
288
+ const exceptions = rec.exceptions.map((e) => ({
289
+ type: e.type,
290
+ severity: e.severity,
291
+ // A policy override may attach a statute/rule citation to the exception; the
292
+ // renderers surface it so the verdict is grounded in the rule. null when the
293
+ // governing policy (or the baseline) supplies none.
294
+ citation: e.citation != null ? e.citation : null,
295
+ amount: e.amount,
296
+ direction: direction(e.amount),
297
+ label: e.label,
298
+ detail: e.detail,
299
+ recordCount: e.records.length,
300
+ records: e.records.map((r) => ({
301
+ date: r.date,
302
+ amount: r.amount,
303
+ memo: r.memo || "",
304
+ party: r.party || "",
305
+ source: r.source || "",
306
+ })),
307
+ }));
308
+
309
+ // A small severity roll-up so the summary line / header can show counts.
310
+ const counts = { error: 0, warning: 0, info: 0 };
311
+ for (const e of exceptions) {
312
+ if (counts[e.severity] === undefined) counts[e.severity] = 0;
313
+ counts[e.severity] += 1;
314
+ }
315
+
316
+ // T-43.2: the ROOT-CAUSE triage over THIS run's classified findings. Computed
317
+ // by the pure reconcile-core lens (reconcile.triage) over the SAME post-policy
318
+ // exceptions the verdict/counts read, so the packet, --json, and the CLI verdict
319
+ // line all surface ONE consistent diagnosis: is this a genuine OUT-OF-TRUST
320
+ // finding (the product's core value) or did the tool merely fail to fully
321
+ // reconcile/classify the data (a data-shape gap to fix and re-run)? PURELY
322
+ // additive — it never changes the PASS/FAIL verdict, the counts, or the exit
323
+ // code; it only makes the existing FAIL legible at first contact.
324
+ const triage = reconcile.triage({ exceptions: rec.exceptions });
325
+
326
+ const matchSummary = {
327
+ matched: matchResult.matched.length,
328
+ unmatchedBank: matchResult.unmatchedA.length,
329
+ unmatchedBook: matchResult.unmatchedB.length,
330
+ };
331
+
332
+ // PASS only when the three balances tie out AND there is no ERROR-severity
333
+ // exception. An out-of-trust finding (commingling, unsegregated deposit) is a
334
+ // FAIL even if the arithmetic happens to net to zero — the gate must protect
335
+ // the beneficiaries, not just the totals. This counts.error gate is
336
+ // exception-TYPE-agnostic: it sees only the post-policy severity, so a policy
337
+ // that escalates a normally-WARNING finding (e.g. an `ambiguous_deposit` whose
338
+ // beneficiary type a state requires be established) to ERROR flips this verdict
339
+ // to FAIL on the SAME files, while with no policy that finding stays a WARNING
340
+ // and does NOT by itself fail a firm with otherwise-clean, tying-out books.
341
+ const pass = rec.tiesOut && counts.error === 0;
342
+
343
+ // Disclaimer: the three baseline lines verbatim (so the no-policy packet is
344
+ // byte-for-byte unchanged), plus ONE extra line naming the governing policy
345
+ // whenever one was selected.
346
+ const disclaimer = DISCLAIMER_LINES.slice();
347
+ if (policyMeta) disclaimer.push(policyDisclaimerLine(policyMeta.state));
348
+
349
+ // The opening balances this period rolled FORWARD from (the prior period's
350
+ // asserted ending, or 0/0 for the first period). Surfaced on the model so the
351
+ // period-close artifact (close.js) can derive a continuity-checkable opening
352
+ // PURELY from the packet, and so the packet is self-describing about what it
353
+ // started from. Same untrusted-hint posture as every other input.
354
+ const openingBalances = {
355
+ bank: opening && Number.isInteger(opening.bank) ? opening.bank : 0,
356
+ book: opening && Number.isInteger(opening.book) ? opening.book : 0,
357
+ };
358
+
359
+ return {
360
+ schema: "trustledger.reconciliation-packet/v1",
361
+ reportDate,
362
+ period: period || null,
363
+ opening: openingBalances,
364
+ // The prior period this run chained from + the roll-forward check (null when
365
+ // no --prior-close was supplied). Names the prior period and shows the
366
+ // roll-forward (prior ending -> this opening) so an auditor sees the chain.
367
+ continuity,
368
+ priorClose: priorMeta,
369
+ // The caller-named path THIS run's close was/will be emitted to (null when no
370
+ // --emit-close). Referenced so the emitted artifact is traceable from the
371
+ // packet; the actual write happens in the CLI, only to this caller-named path.
372
+ emitClose: emitClosePath == null ? null : String(emitClosePath),
373
+ // The policy (state label + the applied overrides/citations) that governed
374
+ // this run, or null for the built-in baseline. Names WHICH policy decided the
375
+ // verdict so the report and --json are self-describing.
376
+ policy: policyMeta,
377
+ disclaimer,
378
+ pass,
379
+ tiesOut: rec.tiesOut,
380
+ balances: rec.balances,
381
+ counts,
382
+ // The root-cause triage (T-43.2): the headline + per-class roll-up that names
383
+ // the make-or-break thing to fix first. Additive: every existing field is
384
+ // unchanged, so a consumer that ignores `triage` is byte-for-byte unaffected.
385
+ triage,
386
+ matchSummary,
387
+ beneficiaries,
388
+ exceptions,
389
+ inputs: {
390
+ bankRecords: bank.length,
391
+ bookRecords: book.length,
392
+ rentrollRecords: rentroll.length,
393
+ },
394
+ };
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // The one-line PASS/FAIL summary (used by the CLI for the human + as the basis
399
+ // of the exit code). Deterministic string for a given model.
400
+ // ---------------------------------------------------------------------------
401
+
402
+ function summaryLine(model) {
403
+ const b = model.balances;
404
+ const verdict = model.pass ? "PASS" : "FAIL";
405
+ const tie = model.tiesOut ? "tie out" : "DO NOT tie out";
406
+ return (
407
+ `${verdict}: three-way reconciliation ${tie} ` +
408
+ `(bank-adjusted ${fmtCents(b.adjustedBank)}, book ${fmtCents(b.book)}, ` +
409
+ `sub-ledger ${fmtCents(b.subledger)}); ` +
410
+ `${model.exceptions.length} exception(s) ` +
411
+ `[${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info]`
412
+ );
413
+ }
414
+
415
+ // T-43.2: the ROOT-CAUSE triage headline, printed by the CLI as a SECOND line
416
+ // AFTER summaryLine (which stays byte-for-byte the existing first line). It names
417
+ // the make-or-break distinction at first contact — a genuine OUT-OF-TRUST finding
418
+ // vs. a data-shape gap the broker fixes and re-runs vs. nothing to fix. The model
419
+ // already carries the computed triage object (buildPacket -> reconcile.triage), so
420
+ // this is a pure read of `model.triage.headline`; it never re-derives or changes
421
+ // the verdict. Falls back to recomputing from the exceptions if an older model
422
+ // (pre-triage) is passed, so the helper is safe to call on any packet model.
423
+ function triageHeadline(model) {
424
+ const t =
425
+ model && model.triage && typeof model.triage.headline === "string"
426
+ ? model.triage
427
+ : reconcile.triage({ exceptions: (model && model.exceptions) || [] });
428
+ return `Triage: ${t.headline}`;
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // HTML renderer — single self-contained, print-to-PDF-ready document.
433
+ // ---------------------------------------------------------------------------
434
+
435
+ function sevBadge(sev) {
436
+ const color =
437
+ sev === "error" ? "#b00020" : sev === "warning" ? "#8a6d00" : "#0a6b2f";
438
+ return `<span class="sev sev-${esc(sev)}" style="color:${color}">${esc(sev.toUpperCase())}</span>`;
439
+ }
440
+
441
+ // T-43.2: the "fix first" triage callout + per-class roll-up. Rendered right under
442
+ // the verdict so a broker reads the make-or-break distinction at first contact: a
443
+ // genuine OUT-OF-TRUST finding (the product's core value) vs. a data-shape gap to
444
+ // fix and re-run vs. only review/timing notes. The callout's tone follows the top
445
+ // class — RED when out of trust, AMBER when the only blockers are data gaps (and
446
+ // it states plainly this is NOT an out-of-trust finding), GREEN otherwise. The
447
+ // roll-up table lists each present class (most-urgent first, the SAME CLASS_RANK
448
+ // order reconcile.triage emits) with its finding count and total dollar impact.
449
+ // EVERY attacker-controllable value (the headline, the class labels) is HTML-
450
+ // escaped via esc(). Reads model.triage verbatim — it never re-derives a verdict.
451
+ function renderTriageSection(model) {
452
+ const t = model.triage;
453
+ if (!t) return ""; // older/forged model with no triage: render nothing (additive)
454
+ // Tone: out_of_trust => fail (red), data_completeness-only => warn (amber),
455
+ // else (review/timing/none) => clean (green). Mirrors triage.headline exactly.
456
+ const tone = t.outOfTrust ? "fail" : t.dataIncomplete ? "warn" : "clean";
457
+ const rows = (t.classes || [])
458
+ .map(
459
+ (c) =>
460
+ `<tr><td>${esc(c.label)}</td>` +
461
+ `<td class="num">${esc(String(c.count))}</td>` +
462
+ `<td class="num">${esc(fmtCents(c.absImpact))}</td></tr>`
463
+ )
464
+ .join("\n");
465
+ const rollup = rows
466
+ ? `<table>
467
+ <thead><tr><th>Fix first &rarr;</th><th class="num">Findings</th><th class="num">Impact</th></tr></thead>
468
+ <tbody>
469
+ ${rows}
470
+ </tbody>
471
+ </table>`
472
+ : "";
473
+ return `
474
+ <div class="triage triage-${tone}">
475
+ <strong>Fix first.</strong> ${esc(t.headline)}
476
+ </div>
477
+ ${rollup}
478
+ `;
479
+ }
480
+
481
+ // The roll-forward continuity section: only rendered when this run chained from a
482
+ // prior period's close (--prior-close). Names the prior period and shows the
483
+ // roll-forward (prior ending -> this opening) for both the bank and book legs so
484
+ // an auditor can walk the chain. When the gap is non-zero the matching
485
+ // CONTINUITY_BREAK row appears in the Exceptions table; this section shows the
486
+ // arithmetic that produced it.
487
+ function renderContinuitySection(model) {
488
+ const c = model.continuity;
489
+ if (!c || !c.checked) return "";
490
+ const priorName =
491
+ c.priorPeriod == null ? c.priorReportDate : `${c.priorPeriod} (${c.priorReportDate})`;
492
+ const okLine = c.ok
493
+ ? `<p class="none">The roll-forward is clean: this period opened exactly where
494
+ the prior period closed.</p>`
495
+ : `<p style="color:#b00020"><strong>Roll-forward break:</strong> the bank
496
+ opening differs from the prior ending by
497
+ <strong>${esc(fmtCents(c.bankGap))}</strong> and the book opening differs by
498
+ <strong>${esc(fmtCents(c.bookGap))}</strong>. See the CONTINUITY_BREAK exception
499
+ below.</p>`;
500
+ const rows = [
501
+ ["Bank", c.priorEnding.bank, c.thisOpening.bank, c.bankGap],
502
+ ["Book", c.priorEnding.book, c.thisOpening.book, c.bookGap],
503
+ ]
504
+ .map(
505
+ ([leg, end, open, gap]) =>
506
+ `<tr><td>${esc(leg)}</td><td class="num">${esc(fmtCents(end))}</td>` +
507
+ `<td class="num">${esc(fmtCents(open))}</td><td class="num">${esc(
508
+ fmtCents(gap)
509
+ )}</td></tr>`
510
+ )
511
+ .join("\n");
512
+ const emitNote = model.emitClose
513
+ ? `<p class="meta">This run's period close was written to
514
+ <strong>${esc(model.emitClose)}</strong>.</p>`
515
+ : "";
516
+ return `
517
+ <h2>Period continuity (roll-forward)</h2>
518
+ <p>This reconciliation chained forward from <strong>${esc(priorName)}</strong>.
519
+ The trust chain requires that this period's OPENING balances equal the prior
520
+ period's signed ENDING balances, to the penny.</p>
521
+ ${okLine}
522
+ <table>
523
+ <thead><tr><th>Leg</th><th class="num">Prior ending</th><th class="num">This opening</th><th class="num">Gap</th></tr></thead>
524
+ <tbody>
525
+ ${rows}
526
+ </tbody>
527
+ </table>
528
+ ${emitNote}
529
+ `;
530
+ }
531
+
532
+ function renderHTML(model) {
533
+ const b = model.balances;
534
+ const passClass = model.pass ? "pass" : "fail";
535
+ const verdict = model.pass ? "PASS — three-way reconciliation ties out" : "FAIL — see exceptions";
536
+
537
+ const balanceRows = [
538
+ ["Bank balance (per statement)", b.bank],
539
+ ["Outstanding / in-transit adjustments", b.adjustedBank - b.bank],
540
+ ["Adjusted bank balance", b.adjustedBank],
541
+ ["Book balance (per ledger)", b.book],
542
+ ["Sub-ledger total (sum of beneficiaries)", b.subledger],
543
+ ]
544
+ .map(
545
+ ([label, cents]) =>
546
+ `<tr><td>${esc(label)}</td><td class="num">${esc(fmtCents(cents))}</td></tr>`
547
+ )
548
+ .join("\n");
549
+
550
+ const benRows = model.beneficiaries
551
+ .map(
552
+ (x) =>
553
+ `<tr><td>${esc(x.party)}</td><td class="num">${esc(fmtCents(x.balance))}</td></tr>`
554
+ )
555
+ .join("\n");
556
+
557
+ const exRows = model.exceptions.length
558
+ ? model.exceptions
559
+ .map((e) => {
560
+ const recs = e.records
561
+ .map(
562
+ (r) =>
563
+ `<div class="rec">${esc(r.date)} &middot; ${esc(fmtCents(r.amount))} &middot; ` +
564
+ `${esc(r.party || "—")} &middot; ${esc(r.memo || "—")} ` +
565
+ `<span class="src">[${esc(r.source || "?")}]</span></div>`
566
+ )
567
+ .join("\n");
568
+ const cite = e.citation
569
+ ? `<div class="cite"><strong>Policy citation:</strong> ${esc(e.citation)}</div>`
570
+ : "";
571
+ return (
572
+ `<tr>` +
573
+ `<td>${sevBadge(e.severity)}</td>` +
574
+ `<td>${esc(e.label)}</td>` +
575
+ `<td class="num">${esc(fmtCents(e.amount))}</td>` +
576
+ `<td>${esc(e.detail)}${cite}${recs ? `<div class="recs">${recs}</div>` : ""}</td>` +
577
+ `</tr>`
578
+ );
579
+ })
580
+ .join("\n")
581
+ : `<tr><td colspan="4" class="none">No exceptions — every line reconciled.</td></tr>`;
582
+
583
+ const disclaimerHTML = model.disclaimer
584
+ .map((p) => `<p>${esc(p)}</p>`)
585
+ .join("\n");
586
+
587
+ // Governing-policy section: only rendered when a per-state policy was selected.
588
+ // Names the policy and lists each severity override with its citation, so the
589
+ // verdict is auditable back to the rule it rests on.
590
+ let policySection = "";
591
+ if (model.policy) {
592
+ const ovRows = model.policy.overrides
593
+ .map(
594
+ (o) =>
595
+ `<tr><td>${esc(o.type)}</td><td>${sevBadge(o.severity)}</td>` +
596
+ `<td>${o.citation ? esc(o.citation) : '<span class="src">(no citation supplied)</span>'}</td></tr>`
597
+ )
598
+ .join("\n");
599
+ const tolNote =
600
+ model.policy.toleranceCents != null
601
+ ? ` The three balances were reconciled under this policy's tolerance of
602
+ <strong>${esc(fmtCents(model.policy.toleranceCents))}</strong> (the band within
603
+ which the balances are treated as tying out).`
604
+ : "";
605
+ policySection = `
606
+ <h2>Governing policy</h2>
607
+ <p>This reconciliation was scored under the per-state trust-rule policy
608
+ <strong>${esc(model.policy.state)}</strong>, which overrides the built-in
609
+ baseline severities as follows. The PASS/FAIL verdict above reflects these
610
+ reviewed severities.${tolNote}</p>
611
+ <table>
612
+ <thead><tr><th>Exception type</th><th>Severity</th><th>Citation</th></tr></thead>
613
+ <tbody>
614
+ ${ovRows || '<tr><td colspan="3" class="none">No overrides.</td></tr>'}
615
+ </tbody>
616
+ </table>
617
+ `;
618
+ }
619
+
620
+ return `<!doctype html>
621
+ <html lang="en">
622
+ <head>
623
+ <meta charset="utf-8">
624
+ <meta name="viewport" content="width=device-width, initial-scale=1">
625
+ <title>TrustLedger Reconciliation Packet — ${esc(model.reportDate)}</title>
626
+ <style>
627
+ :root { color-scheme: light; }
628
+ body { font: 14px/1.5 -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
629
+ color: #1a1a1a; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
630
+ h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
631
+ h2 { font-size: 1.1rem; border-bottom: 2px solid #eee; padding-bottom: .25rem; margin-top: 2rem; }
632
+ .meta { color: #555; font-size: .9rem; }
633
+ .verdict { display: inline-block; padding: .4rem .8rem; border-radius: 6px;
634
+ font-weight: 700; margin: 1rem 0; }
635
+ .verdict.pass { background: #e6f4ea; color: #0a6b2f; border: 1px solid #0a6b2f; }
636
+ .verdict.fail { background: #fdeaea; color: #b00020; border: 1px solid #b00020; }
637
+ .triage { border-radius: 6px; padding: .6rem .9rem; margin: 1rem 0; font-size: .95rem;
638
+ border-left-width: 5px; border-left-style: solid; }
639
+ .triage-fail { background: #fdeaea; color: #7a0016; border-left-color: #b00020; }
640
+ .triage-warn { background: #fff7ea; color: #6b3a00; border-left-color: #e6b800; }
641
+ .triage-clean { background: #e6f4ea; color: #0a4d22; border-left-color: #0a6b2f; }
642
+ table { border-collapse: collapse; width: 100%; margin: .5rem 0 1rem; }
643
+ th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #eee; vertical-align: top; }
644
+ th { background: #fafafa; font-weight: 600; }
645
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
646
+ .sev { font-weight: 700; font-size: .8rem; }
647
+ .recs { margin-top: .35rem; }
648
+ .rec { font-size: .82rem; color: #444; }
649
+ .src { color: #999; }
650
+ .cite { font-size: .82rem; color: #6b3a00; background: #fff7ea; border-left: 3px solid #e6b800;
651
+ padding: .25rem .5rem; margin: .35rem 0; }
652
+ .none { color: #0a6b2f; }
653
+ .disclaimer { background: #fffbe6; border: 1px solid #e6d77a; border-radius: 6px;
654
+ padding: .75rem 1rem; margin: 1rem 0; font-size: .9rem; }
655
+ .disclaimer p { margin: .4rem 0; }
656
+ footer { margin-top: 2rem; color: #888; font-size: .8rem; border-top: 1px solid #eee; padding-top: .5rem; }
657
+ @media print { body { margin: 0; max-width: none; } h2 { page-break-after: avoid; } tr { page-break-inside: avoid; } }
658
+ </style>
659
+ </head>
660
+ <body>
661
+ <h1>TrustLedger — Three-Way Trust-Account Reconciliation</h1>
662
+ <p class="meta">Report date: <strong>${esc(model.reportDate)}</strong>${
663
+ model.period ? ` &middot; Period: <strong>${esc(model.period)}</strong>` : ""
664
+ } &middot; Policy: <strong>${esc(
665
+ model.policy ? model.policy.state : "Built-in baseline severities"
666
+ )}</strong></p>
667
+
668
+ <div class="verdict ${passClass}">${esc(verdict)}</div>
669
+ ${renderTriageSection(model)}
670
+ <div class="disclaimer">
671
+ <strong>Disclaimer.</strong>
672
+ ${disclaimerHTML}
673
+ </div>
674
+ ${policySection}
675
+ ${renderContinuitySection(model)}
676
+ <h2>The three balances</h2>
677
+ <p>The trust account is in balance only when the adjusted bank balance, the book
678
+ balance, and the sum of the beneficiary sub-ledgers all agree.</p>
679
+ <table>
680
+ <thead><tr><th>Balance</th><th class="num">Amount</th></tr></thead>
681
+ <tbody>
682
+ ${balanceRows}
683
+ </tbody>
684
+ </table>
685
+ <p>Reconciled balance:
686
+ <strong>${esc(b.reconciled == null ? "— (does not tie out)" : fmtCents(b.reconciled))}</strong>.</p>
687
+
688
+ <h2>Beneficiary sub-ledger</h2>
689
+ <table>
690
+ <thead><tr><th>Beneficiary</th><th class="num">Balance</th></tr></thead>
691
+ <tbody>
692
+ ${benRows || '<tr><td colspan="2" class="none">No beneficiaries in the rent roll.</td></tr>'}
693
+ </tbody>
694
+ </table>
695
+
696
+ <h2>Exceptions (${model.exceptions.length})</h2>
697
+ <p>${model.counts.error} error &middot; ${model.counts.warning} warning &middot; ${model.counts.info} info.
698
+ Errors mean the trust account may be out of trust and must be resolved before signing.</p>
699
+ <table>
700
+ <thead><tr><th>Severity</th><th>Finding</th><th class="num">Amount</th><th>Detail</th></tr></thead>
701
+ <tbody>
702
+ ${exRows}
703
+ </tbody>
704
+ </table>
705
+
706
+ <h2>Match summary</h2>
707
+ <table>
708
+ <tbody>
709
+ <tr><td>Bank/book lines matched</td><td class="num">${esc(String(model.matchSummary.matched))}</td></tr>
710
+ <tr><td>Unmatched bank lines</td><td class="num">${esc(String(model.matchSummary.unmatchedBank))}</td></tr>
711
+ <tr><td>Unmatched book lines</td><td class="num">${esc(String(model.matchSummary.unmatchedBook))}</td></tr>
712
+ <tr><td>Input records (bank / book / rent roll)</td><td class="num">${esc(
713
+ String(model.inputs.bankRecords)
714
+ )} / ${esc(String(model.inputs.bookRecords))} / ${esc(String(model.inputs.rentrollRecords))}</td></tr>
715
+ </tbody>
716
+ </table>
717
+
718
+ <footer>
719
+ Generated by TrustLedger ${esc(model.schema)}. Deterministic for the given inputs and report date.
720
+ To file as PDF, open this file in a browser and choose Print &rarr; Save as PDF.
721
+ </footer>
722
+ </body>
723
+ </html>
724
+ `;
725
+ }
726
+
727
+ // ---------------------------------------------------------------------------
728
+ // CSV renderer — the exception list as a spreadsheet (one row per record, with
729
+ // summary rows above). Always ends with a trailing newline. Deterministic.
730
+ // ---------------------------------------------------------------------------
731
+
732
+ function renderExceptionsCSV(model) {
733
+ const lines = [];
734
+ const row = (...fields) => lines.push(fields.map(csvField).join(","));
735
+
736
+ row(
737
+ "severity",
738
+ "type",
739
+ "label",
740
+ "amount_cents",
741
+ "amount",
742
+ "direction",
743
+ "record_date",
744
+ "record_amount",
745
+ "record_party",
746
+ "record_memo",
747
+ "record_source",
748
+ "detail",
749
+ "policy_citation"
750
+ );
751
+
752
+ for (const e of model.exceptions) {
753
+ const cite = e.citation || "";
754
+ if (e.records.length === 0) {
755
+ row(
756
+ e.severity,
757
+ e.type,
758
+ e.label,
759
+ String(e.amount),
760
+ fmtCents(e.amount),
761
+ e.direction,
762
+ "",
763
+ "",
764
+ "",
765
+ "",
766
+ "",
767
+ e.detail,
768
+ cite
769
+ );
770
+ continue;
771
+ }
772
+ for (const r of e.records) {
773
+ row(
774
+ e.severity,
775
+ e.type,
776
+ e.label,
777
+ String(e.amount),
778
+ fmtCents(e.amount),
779
+ e.direction,
780
+ r.date,
781
+ fmtCents(r.amount),
782
+ r.party,
783
+ r.memo,
784
+ r.source,
785
+ e.detail,
786
+ cite
787
+ );
788
+ }
789
+ }
790
+ return lines.join("\n") + "\n";
791
+ }
792
+
793
+ // A second CSV: the three balances + beneficiary sub-ledger, so the broker can
794
+ // open the headline numbers in a spreadsheet too. Deterministic.
795
+ function renderBalancesCSV(model) {
796
+ const b = model.balances;
797
+ const lines = [];
798
+ const row = (...fields) => lines.push(fields.map(csvField).join(","));
799
+ row("section", "label", "amount_cents", "amount");
800
+ // Name the governing policy so the spreadsheet is self-describing too.
801
+ row(
802
+ "policy",
803
+ model.policy ? model.policy.state : "Built-in baseline severities",
804
+ "",
805
+ ""
806
+ );
807
+ row("balance", "bank", String(b.bank), fmtCents(b.bank));
808
+ row("balance", "adjusted_bank", String(b.adjustedBank), fmtCents(b.adjustedBank));
809
+ row("balance", "book", String(b.book), fmtCents(b.book));
810
+ row("balance", "subledger", String(b.subledger), fmtCents(b.subledger));
811
+ row(
812
+ "balance",
813
+ "reconciled",
814
+ b.reconciled == null ? "" : String(b.reconciled),
815
+ b.reconciled == null ? "" : fmtCents(b.reconciled)
816
+ );
817
+ for (const x of model.beneficiaries) {
818
+ row("beneficiary", x.party, String(x.balance), fmtCents(x.balance));
819
+ }
820
+ // Roll-forward continuity rows (only when this run chained from a prior close),
821
+ // so the spreadsheet names the prior period and shows the chain too.
822
+ const c = model.continuity;
823
+ if (c && c.checked) {
824
+ const priorLabel =
825
+ c.priorPeriod == null ? c.priorReportDate : `${c.priorPeriod} (${c.priorReportDate})`;
826
+ row("continuity", "prior_period", "", priorLabel);
827
+ row("continuity", "prior_ending_bank", String(c.priorEnding.bank), fmtCents(c.priorEnding.bank));
828
+ row("continuity", "prior_ending_book", String(c.priorEnding.book), fmtCents(c.priorEnding.book));
829
+ row("continuity", "this_opening_bank", String(c.thisOpening.bank), fmtCents(c.thisOpening.bank));
830
+ row("continuity", "this_opening_book", String(c.thisOpening.book), fmtCents(c.thisOpening.book));
831
+ row("continuity", "bank_gap", String(c.bankGap), fmtCents(c.bankGap));
832
+ row("continuity", "book_gap", String(c.bookGap), fmtCents(c.bookGap));
833
+ row("continuity", "roll_forward_ok", c.ok ? "1" : "0", c.ok ? "clean" : "BREAK");
834
+ }
835
+ if (model.emitClose) {
836
+ row("close", "emitted_to", "", model.emitClose);
837
+ }
838
+ return lines.join("\n") + "\n";
839
+ }
840
+
841
+ // ---------------------------------------------------------------------------
842
+ // Filenames — DATED and deterministic. The packet writes a stable set of files
843
+ // into the caller-chosen directory; the names embed the report date so multiple
844
+ // months can coexist in one directory without collision.
845
+ // ---------------------------------------------------------------------------
846
+
847
+ function packetFilenames(reportDate) {
848
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(reportDate || ""))) {
849
+ throw new ReportError('reportDate must be a "YYYY-MM-DD" string');
850
+ }
851
+ const stamp = reportDate; // already filesystem-safe (no slashes)
852
+ return {
853
+ html: `reconciliation-${stamp}.html`,
854
+ exceptionsCsv: `reconciliation-${stamp}-exceptions.csv`,
855
+ balancesCsv: `reconciliation-${stamp}-balances.csv`,
856
+ };
857
+ }
858
+
859
+ // Render every artifact for a model. Returns { filename: contents } so the
860
+ // caller does the actual writes (keeping this module I/O-free and testable).
861
+ function renderPacket(model) {
862
+ const names = packetFilenames(model.reportDate);
863
+ return {
864
+ [names.html]: renderHTML(model),
865
+ [names.exceptionsCsv]: renderExceptionsCSV(model),
866
+ [names.balancesCsv]: renderBalancesCSV(model),
867
+ };
868
+ }
869
+
870
+ module.exports = {
871
+ ReportError,
872
+ DISCLAIMER_LINES,
873
+ DISCLAIMER_TEXT,
874
+ buildPacket,
875
+ summaryLine,
876
+ triageHeadline,
877
+ renderHTML,
878
+ renderExceptionsCSV,
879
+ renderBalancesCSV,
880
+ renderPacket,
881
+ packetFilenames,
882
+ // small helpers exported for focused tests / reuse
883
+ fmtCents,
884
+ csvField,
885
+ // re-export the pipeline pieces so a caller can ingest with one require
886
+ ingest,
887
+ };