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,1976 @@
1
+ # TrustLedger — automated three-way trust-account reconciliation
2
+
3
+ TrustLedger takes the three files a small US residential property-management firm already has every
4
+ month — the **bank statement**, the **QuickBooks ledger** (the "book"), and the **rent roll**
5
+ (per-tenant sub-ledger) — and runs the whole reconciliation end to end in one command: it parses each
6
+ file, matches bank lines to book lines, computes the **three balances that must legally agree**, flags
7
+ every exception, and writes a **dated, audit-ready reconciliation packet** (HTML + CSV) you can file as
8
+ evidence of the reconciliation.
9
+
10
+ ```
11
+ vh trust reconcile <bank> <ledger> <rentroll> [--out <dir>]
12
+ ```
13
+
14
+ The whole pipeline is a **deterministic parser / matcher / reporter**: integer-cents arithmetic
15
+ throughout (no floating-point drift), an injected report date (no hidden clock), and byte-reproducible
16
+ output. Given the same three files and the same date, it produces the same packet every run — which is
17
+ exactly what a reconciliation a broker signs and an auditor reads must be.
18
+
19
+ > **Read this first — what this tool is, and is NOT.** TrustLedger is a **tool that AIDS
20
+ > reconciliation**. The broker remains the legal trust-account custodian and is solely responsible for
21
+ > the accuracy and completeness of the trust-account records and for compliance with all applicable
22
+ > state trust-fund rules. TrustLedger reconciles the files it is given; it cannot see transactions
23
+ > absent from those files, cannot judge whether a transaction is itself proper, and does not constitute
24
+ > legal, accounting, or audit advice. **A PASS does not certify legal compliance.** Have a qualified CPA
25
+ > or your state regulator review the packet — including the disclaimer wording and the
26
+ > exception-severity classification — before relying on it. This same disclaimer leads every packet the
27
+ > tool emits (`trustledger/report.js` › `DISCLAIMER_LINES`, the single source of truth), and the
28
+ > classification rules below are **state- and CPA-dependent** policy that is pending human review
29
+ > (STRATEGY.md › Proposals › P-5).
30
+
31
+ ---
32
+
33
+ ## Who buys this, and why
34
+
35
+ The buyer is the **broker of record** at a small residential property-management firm (~50–500 doors)
36
+ that runs on QuickBooks + a bank CSV + a rent ledger — not on AppFolio or Buildium, which already do
37
+ this. In most US states the broker is the **legal custodian of the trust account** that holds other
38
+ people's money (tenant rent, owner funds, security deposits) and carries **personal license risk** if
39
+ that account goes out of trust. The three-way reconciliation is a **legally-forced, recurring monthly
40
+ chore**, so willingness-to-pay is high and externally imposed.
41
+
42
+ This is a *different* paying buyer than DataLedger's data-provenance reviewer or ProofParcel's data
43
+ vendor — a focused income bet, reachable purely through high-intent SEO/ads and NARPM forums, with no
44
+ insider network required.
45
+
46
+ ---
47
+
48
+ ## The three balances (what "ties out" means)
49
+
50
+ A trust account is **in trust** when three independently-derived numbers agree:
51
+
52
+ | Balance | What it is | Source file |
53
+ | --- | --- | --- |
54
+ | **Adjusted bank** | the bank statement balance, corrected for outstanding/in-transit items (deposits in transit, uncleared checks) | bank statement |
55
+ | **Book** | the opening balance plus the ledger's recorded activity | QuickBooks ledger |
56
+ | **Sub-ledger total** | the sum of every per-beneficiary (per-tenant/owner) balance | rent roll |
57
+
58
+ Two equalities must hold: **adjusted bank == book** (the bank and the books agree once timing items are
59
+ accounted for) and **book == sub-ledger total** (the money in the account is, *in total*, accounted for
60
+ to its beneficiaries). When both hold, the three-balance arithmetic **ties out**.
61
+
62
+ > **A tie-out alone does NOT prove "nothing is commingled or missing."** The second equality is a check
63
+ > on the **pooled SUM** of every per-beneficiary balance, and a sum is necessary but **not sufficient**:
64
+ > one beneficiary's **surplus can exactly mask another beneficiary's deficit**, so the pool ties to the
65
+ > penny while one tenant's trust money has in fact been spent — or used to cover another beneficiary's
66
+ > shortfall. A pooled tie-out therefore proves the *total* is accounted for; it does **not** prove that
67
+ > **each individual** beneficiary's money is intact. (The earlier wording — "nothing is commingled or
68
+ > missing" — overclaimed this and is corrected here.)
69
+
70
+ So **in trust** requires a third, **per-beneficiary** requirement beyond the two pooled equalities: the
71
+ **no-negative-individual-ledger** rule — **no single beneficiary's own sub-ledger may be negative**.
72
+ A negative individual ledger means the broker is holding *less than zero* in trust for that person
73
+ (their money was spent or used to cover another beneficiary's shortfall), so it is **out of trust on its
74
+ own** even when the pooled sum ties. The pipeline raises that as the **`negative_tenant_ledger`** finding,
75
+ whose default severity is **ERROR** — it FAILs the gate (exit `3`) **independently of whether the SUM
76
+ ties** (both checks can fire at once). It is the per-beneficiary guard that closes the "surplus masks a
77
+ deficit" hole the pooled tie-out leaves open; control/sink accounts are excluded. See
78
+ **`negative_tenant_ledger`** under *The policy file schema* below for the full rule
79
+ (`trustledger/reconcile.js` › `classifyNegativeTenantLedgers`).
80
+
81
+ The **security-deposit segregation** check is, likewise, deliberately hard to fool. It guards against
82
+ **two** distinct ways an un-segregated deposit could *silently* clear — neither of which it allows
83
+ (`trustledger/reconcile.js`). See **Security-deposit segregation: per-beneficiary, single-source**
84
+ below for the full rule.
85
+
86
+ ---
87
+
88
+ ## Security-deposit segregation: per-beneficiary, single-source
89
+
90
+ The flagship out-of-trust finding (`security_deposit_segregation`, an **ERROR** that FAILs the gate) is
91
+ the one a broker most needs to be **un-foolable**: a security deposit the broker received but never moved
92
+ to a segregated account is exactly the commingling state regulators sanction for. A naïve "did the
93
+ deposits add up to the transfers?" total has **two** silent-false-pass holes, and TrustLedger closes
94
+ **both**. A segregation transfer's coverage is therefore counted **(1) from a single source** and
95
+ **(2) matched per beneficiary** before any deposit is considered covered.
96
+
97
+ ### Mechanism 1 — single-source counting (one source, not two)
98
+
99
+ A single real segregation transfer is recorded **twice**: once in the QuickBooks **book** and once on the
100
+ **bank** statement — it is the *same* money movement seen from two sources, and `match.js` pairs the two
101
+ copies. Summing coverage across **both** sources would count one $X transfer as **$2X** of coverage,
102
+ which can silently clear a genuinely un-segregated deposit — a false negative on the very finding the
103
+ product exists to catch. So coverage is counted from **one** authoritative source (**the book**); the
104
+ bank-side copy is the mirror of the same movement and **adds no new segregation**, so it adds no coverage
105
+ (`trustledger/reconcile.js` — the bank list is intentionally unused for the segregation sum). This is the
106
+ "one source" rule: it cannot silently clear an un-segregated deposit by **double-counting one transfer**.
107
+
108
+ ### Mechanism 2 — per-beneficiary matching (no spill between tenants)
109
+
110
+ Trust law requires **each** tenant's deposit be held **separately**, so coverage is matched **per
111
+ beneficiary** — never from a single pooled total (T-40.1). A transfer attributed to tenant **X** covers
112
+ **only X's** deposits; its excess does **not** spill onto another tenant **Y's** un-segregated deposit.
113
+ A pooled total hides a real shortage whenever one tenant is **over-segregated** and another is
114
+ **under-segregated** by the same amount: the totals net to zero and the naïve check **PASSes**, even
115
+ though tenant Y's deposit is sitting un-segregated. Per-beneficiary matching pins each tenant's surplus
116
+ to **that tenant**, so Y's deposit is correctly **FLAGGED** and the at-risk beneficiary is **named** in
117
+ the finding (T-40.2). A transfer that names **no** recognizable beneficiary stays a **generic residual
118
+ pool** that can clear at most a still-uncovered deposit — it can never silently absorb one tenant's
119
+ shortage into another's surplus. This is the per-beneficiary rule: it cannot silently clear an
120
+ un-segregated deposit by **netting one tenant's shortage against another tenant's surplus**.
121
+
122
+ Together the two mechanisms make the segregation check **strictly non-looser** than a naïve total: each
123
+ can only **ADD or RE-ATTRIBUTE** a finding, never **remove** a real one. Both are pure, deterministic
124
+ free-text/structured classification in `trustledger/reconcile.js` (`classifySecurityDeposits` /
125
+ `attributeSegregation`): no clock, no I/O, byte-reproducible.
126
+
127
+ > **DRAFT / NOT LEGAL ADVICE.** The policies that SHIP with TrustLedger
128
+ > (`trustledger/fixtures/policy/*.json`) are **DRAFT skeletons**, not legal advice and **not a claim of
129
+ > regulatory compliance**. The baseline reproduces the built-in defaults verbatim; the example state
130
+ > file carries a **PLACEHOLDER** citation. A qualified **CPA and/or counsel must review and SIGN** the
131
+ > per-state severity mapping and its statute citations for the actual jurisdiction before the gate is
132
+ > relied on. Selecting a policy does **not** make a packet legal advice and does **not** discharge the
133
+ > broker's duty as the responsible legal custodian of trust funds. (STRATEGY.md › P-5 #1/#2.)
134
+
135
+ Whether a flagged un-segregated deposit is graded ERROR (the baseline) or re-graded by a state is a
136
+ **per-state CPA decision via the existing policy layer** — `security_deposit_segregation` is one of the
137
+ **legal exception types** a reviewed policy MAY re-grade, exactly like every other type, with **no engine
138
+ change** and **no new `needs-human` item** beyond the per-state policy sign-off P-5 #2 already tracks.
139
+
140
+ ---
141
+
142
+ ## PASS / FAIL and the exit-code contract
143
+
144
+ The command prints a one-line verdict and exits with a **stable, CI-gateable** code:
145
+
146
+ | Exit | Meaning |
147
+ | --- | --- |
148
+ | `0` | **PASS** — the three balances tie out AND there is no error-severity finding |
149
+ | `3` | **FAIL** — the balances do not tie out, OR an out-of-trust (error-severity) finding exists |
150
+ | `2` | usage error (missing/extra arguments, bad flag) |
151
+ | `1` | input/IO error (a file is unreadable or malformed) |
152
+
153
+ **PASS requires BOTH that the arithmetic ties out AND that there is zero error-severity finding.** An
154
+ out-of-trust account therefore **FAILs even when the totals happen to net to zero** — the gate protects
155
+ the beneficiaries, not just the column sums (`trustledger/report.js`).
156
+
157
+ You can wire this directly into CI / a monthly automation: a non-zero exit blocks the close.
158
+
159
+ ---
160
+
161
+ ## The correctness corpus: `vh trust corpus` — run this to confirm the gate is correct
162
+
163
+ The single defensible, monetizable claim TrustLedger makes is its **correctness**: *a FAIL means your
164
+ trust account is genuinely out of trust, and a PASS means the canonical frauds were checked and not
165
+ found.* That claim lives in `test/`, which the two humans who gate the money — the **CPA who signs the
166
+ disclaimer** and the **broker deciding to pay** — will never read. The **corpus** makes that claim
167
+ something they can confirm in **one read-only command**, instead of trusting a paragraph:
168
+
169
+ ```
170
+ vh trust corpus [--json]
171
+ ```
172
+
173
+ `corpus` loads a **committed library of out-of-trust scenarios** (`trustledger/fixtures/corpus/`), drives
174
+ **every** scenario through the **REAL** reconcile + verdict path — the **same** `report.buildPacket`
175
+ verdict the live `reconcile` exit code uses (`trustledger/corpus.js` is a faithful caller: no second
176
+ engine, no crypto, no severity rule of its own) — and prints a **deterministic** per-scenario table:
177
+
178
+ ```
179
+ SCENARIO CONTROL EXPECT ACTUAL RESULT
180
+ bank-book-mismatch--out-of-trust bank_book_mismatch FAIL FAIL OK
181
+ principle: After outstanding/in-transit items, the adjusted bank balance must equal the book balance.
182
+
183
+ CORPUS OK: 12/12 scenarios match their recorded verdict.
184
+ ```
185
+
186
+ Each row names the scenario `id`, the **control** it exercises, the **expected** verdict the fixture
187
+ records, the **actual** verdict the live engine produced, and `OK`/`MISMATCH`; the one-sentence **trust-law
188
+ principle** prints under the row. The **exit code IS the gate**: `0` only when **every** scenario's live
189
+ verdict matches its recorded verdict (`CORPUS OK`); `3` on **any** mismatch (`CORPUS DRIFT` — a regression
190
+ in the gate or in the corpus); `2` on an unknown flag; `1` if the corpus cannot be loaded. The command
191
+ **writes nothing**, and `--json` carries the structured rows + summary so a pipeline can gate on the data.
192
+
193
+ ### The scenarios — each control, in matched out-of-trust / benign-twin pairs
194
+
195
+ The corpus pairs **every** out-of-trust control with a **benign near-twin** that differs by the **one**
196
+ variable under test, so the corpus proves the gate FAILs the fraud **and** does not over-FAIL its
197
+ innocent look-alike. The committed pairs, by control:
198
+
199
+ | Control | Out-of-trust scenario (→ FAIL) | Benign twin (→ PASS) | The trust-law principle |
200
+ | --- | --- | --- | --- |
201
+ | `security_deposit_segregation` | a tenant's security-deposit receipt sits in the pooled book with **no** offsetting transfer to a segregated account | the **same** receipt, but a matching transfer moves the deposit out to a segregated escrow | Each tenant's security deposit must be held **separately** from operating trust funds; an un-segregated deposit is commingled trust money — out of trust. |
202
+ | `subledger_out_of_balance` | the book records $1,500 of rent but the per-beneficiary sub-ledger accounts for only $1,000 — a $500 gap belonging to no one's ledger | the sub-ledger records the **full** $1,500 against the beneficiary | The pooled account holds one number that must equal the **sum** of every per-beneficiary sub-ledger; money attributed to no beneficiary is out of trust. |
203
+ | `negative_tenant_ledger` | the pooled sub-ledger SUM ties to the book exactly, but one tenant is credited $1,500 and another **−$500** — one beneficiary robbed to cover another | each tenant is credited their **own** $500; every individual ledger is non-negative and the SUM still ties | A negative **individual** ledger means the broker holds *less than zero* in trust for that person — flagged **regardless of whether the pooled SUM ties**. |
204
+ | `owner_overdraw` | an owner contributes $1,000 of own capital but draws $1,500 — paying itself $500 of tenants' pooled trust money; the pooled SUM still ties (a silent pass before this check) | the owner draws exactly $1,000 — entirely from its **own** capital, touching no tenant money | An owner may be disbursed **only** from that owner's own contributed capital; the over-capital excess is a conversion of trust funds — out of trust **even when the SUM ties**. |
205
+ | `bank_book_mismatch` | the activity lines match but the bank opened **$500 short** of the book: the books say $2,000, the bank holds $1,500 — a genuine cash shortage | the bank opened at the **same** balance as the book; the adjusted bank equals the book exactly | After outstanding/in-transit items, the **adjusted bank** balance must equal the **book**; a bank-SHORT gap is missing cash — the textbook out-of-trust case. |
206
+ | `continuity_break` | the prior period closed at $2,500 / $2,500 but this period opens at **$0 / $0** — a skipped/edited/re-keyed period breaks the chain of custody | this period opens **exactly** where the prior closed ($2,500 / $2,500); the roll-forward is clean | Each period's **opening** must equal the prior period's signed **ending**, to the penny; a non-zero roll-forward gap breaks the chain of custody. |
207
+
208
+ Three of these — `negative_tenant_ledger`, `owner_overdraw`, and the pooled segregation netting — are the
209
+ **silent-false-pass** cases the corpus exists to make legible: the three-way **pooled SUM ties out
210
+ perfectly**, so a naïve total would PASS, yet the account is genuinely out of trust for an individual
211
+ beneficiary. The corpus is the one-command proof that TrustLedger FAILs each of these **regardless of the
212
+ pooled tie-out** — the exact claim a CPA most needs to confirm and a broker most needs to believe.
213
+
214
+ ### What a green corpus DOES and DOES NOT mean
215
+
216
+ > **`CORPUS OK` confirms the GATE's behaviour — it does NOT certify a jurisdiction or constitute legal
217
+ > advice.** A green corpus run proves **one** thing, precisely: on the committed fixtures, through the
218
+ > **same** engine path the real `reconcile` exit uses, the gate **FAILs** each canonical out-of-trust
219
+ > fraud and **PASSes** each benign twin — so the correctness claim is checkable in one command rather than
220
+ > on faith. It does **NOT** certify that any **state's** trust-fund rules are satisfied, does **NOT**
221
+ > certify a jurisdiction's severity mapping (that is the still-DRAFT per-state policy a CPA must sign —
222
+ > see **The per-state policy layer**), does **NOT** audit a real broker's books, and does **NOT**
223
+ > constitute legal, accounting, or audit advice. It confirms the **tool's gate is correct**, not that any
224
+ > particular **account is compliant**. The custodian/CPA posture below is unchanged: TrustLedger **aids**
225
+ > reconciliation, the broker remains the **responsible legal trust-account custodian**, a **PASS does not
226
+ > certify legal compliance**, and a qualified **CPA** must still review the packet. Running the corpus
227
+ > replaces "trust our disclaimer" with "run one command and watch the gate catch the exact frauds it
228
+ > claims to" — it does not replace the human review it makes faster.
229
+
230
+ Because the corpus only ever asserts the **existing** verdict, it adds **no** new behaviour and **no** new
231
+ human gate: if a corpus case ever fails to behave as recorded, that is a **bug to fix in the engine**, never
232
+ a corpus to weaken. It rides the **same** DRAFT / NOT-LEGAL-ADVICE posture the rest of this document carries,
233
+ **verbatim**:
234
+
235
+ > **DRAFT / NOT LEGAL ADVICE.** The policies that SHIP with TrustLedger
236
+ > (`trustledger/fixtures/policy/*.json`) are **DRAFT skeletons**, not legal advice and **not a claim of
237
+ > regulatory compliance**. The baseline reproduces the built-in defaults verbatim; the example state
238
+ > file carries a **PLACEHOLDER** citation. A qualified **CPA and/or counsel must review and SIGN** the
239
+ > per-state severity mapping and its statute citations for the actual jurisdiction before the gate is
240
+ > relied on. Selecting a policy does **not** make a packet legal advice and does **not** discharge the
241
+ > broker's duty as the responsible legal custodian of trust funds. (STRATEGY.md › P-5 #1/#2.)
242
+
243
+ The corpus is a **DRAFT** legibility/correctness aid layered over the **same DRAFT** severity policy a CPA
244
+ signs — there is **no** new `needs-human` item beyond the P-5 design-partner / CPA sign-off the rest of this
245
+ document already tracks.
246
+
247
+ ---
248
+
249
+ ## FAIL triage: what to fix first (the pilot's first-contact legibility)
250
+
251
+ A bare FAIL is a **count, not a cause**. The verdict line
252
+ (`FAIL: … DO NOT tie out …; N exception(s) [X error, Y warning, Z info]`) tells a broker *how many*
253
+ findings there are, not *which one decides whether their license is at risk*. For a non-technical
254
+ broker meeting the tool for the **first time** — the make-or-break moment of a P-5 #3 design-partner
255
+ pilot — that single missing distinction is the difference between "this tool found a real problem I
256
+ must fix" and "this tool is broken." A FAIL a pilot broker reads as *the tool can't handle my files*
257
+ loses the pilot no matter how correct the math is; a FAIL they read as *fix this one $1,250
258
+ unreconciled deposit, then you're clean* wins it.
259
+
260
+ The **triage layer** closes that gap. It is a pure, deterministic read over the **same classified
261
+ exceptions** the verdict already carries (`trustledger/reconcile.js` › `triage`, surfaced by
262
+ `trustledger/report.js` › `triageHeadline` / the HTML "Fix first" callout): it partitions every finding
263
+ into a **root-cause class**, rolls each class up by **dollar impact** (summed absolute integer cents),
264
+ and emits **one headline** naming the single highest-priority thing to fix. It reads the verdict; it
265
+ does **not** change it.
266
+
267
+ ### The four root-cause classes
268
+
269
+ Every exception type maps to exactly **one** of four classes (a closed, enum-derived map with a
270
+ **load-time exhaustiveness guard** — a new or typo'd exception type is a build error, never silently
271
+ unclassified):
272
+
273
+ | Class | What it means | What the broker should do |
274
+ | --- | --- | --- |
275
+ | **`out_of_trust`** | a real shortage / commingling / conversion — the trust account is **genuinely out of trust** (an un-segregated security deposit, the sub-ledger out of balance, a negative individual ledger, an owner over-draw, a broken roll-forward, or the bank holding **less** cash than the books) | **Fix the trust account.** This is the product delivering its core value, NOT "the tool is broken." |
276
+ | **`data_completeness`** | the tool **could not fully reconcile/classify the data**: an unmatched bank/book line, an undetermined deposit type (`ambiguous_deposit`), or the bank holding **more** than the books record (an unrecorded deposit to write down) | **Fix the data and re-run.** A data-shape gap — NOT (yet) evidence the money is gone. |
277
+ | **`needs_review`** | a real movement that **may be legitimate** but a human must eyeball (an owner draw within the owner's own capital, an NSF reversal) | Confirm it; it does not by itself FAIL the gate. |
278
+ | **`timing`** | a benign, **self-clearing** reconciling item (a deposit in transit, an outstanding check) | Expected; it explains a gap rather than being a finding. |
279
+
280
+ `bank_book_mismatch` is **directional**: the residual gap `amount = adjustedBank − book` routes by
281
+ sign — **negative** (the bank holds *less* than the books say — cash is missing) is **`out_of_trust`**;
282
+ **non-negative** (the bank holds *more* — an unrecorded deposit/posting omission) is
283
+ **`data_completeness`**. Routing a bank-SHORT shortage to `data_completeness` would emit a
284
+ confidently-wrong, reassuring "FIX YOUR DATA" headline over a real missing-cash shortage, so the sign is
285
+ load-bearing. (`ambiguous_deposit` is `data_completeness` — it *might* hide an un-segregated deposit,
286
+ but as-is it is a classification gap the broker resolves by labeling the row; `continuity_break` is
287
+ `out_of_trust` — a broken chain of custody, not a tidy-up.)
288
+
289
+ ### The headline priority — and the out-of-trust-vs-fix-my-data distinction
290
+
291
+ The headline names the **single** thing to fix first by a **fixed priority** (most-urgent class first:
292
+ `out_of_trust` → `data_completeness` → `needs_review` → `timing`), and it never blurs the
293
+ **make-or-break distinction**:
294
+
295
+ 1. **Any `out_of_trust` finding** ⇒ the headline **LEADS with "OUT OF TRUST"** (the core product
296
+ verdict) and names the finding count + total dollars — **even if** data-completeness gaps also exist
297
+ (those are noted as secondary, never allowed to soften a genuine shortage into a mere data note).
298
+ 2. **Else any `data_completeness` gap** ⇒ "**FIX YOUR DATA**: the trust account is **NOT** shown out of
299
+ trust — the tool could not fully reconcile your data … re-run; **this is not (yet) evidence the money
300
+ is gone**." A fixable data-shape gap, stated **explicitly NOT** as an out-of-trust claim.
301
+ 3. **Else** (only `needs_review` / `timing`, or nothing) ⇒ the account is **not** shown out of trust and
302
+ the data reconciled; the remainder are review/timing notes for a human to confirm.
303
+
304
+ That `out_of_trust` vs. `data_completeness` split is the whole point: it lets the pilot broker read a
305
+ FAIL correctly at first contact — *my trust account is short* versus *my export needs one fix and a
306
+ re-run* — instead of as an undifferentiated "the tool is broken."
307
+
308
+ ### Triage EXPLAINS the verdict — it does NOT change it
309
+
310
+ The triage layer is **strictly additive** and **changes no verdict**. It is a read-only lens over the
311
+ already-classified findings: it computes **no** balance, alters **no** `tiesOut`, **no** severity, **no**
312
+ exception count, **no** PASS/FAIL, and **no** exit code. The verdict line is byte-for-byte what it always
313
+ was; the headline is printed as a **second** human line beneath it (and added as a `triage` object to
314
+ `--json` and a "Fix first" callout to the HTML packet), so a consumer that ignores `triage` is
315
+ unaffected. A finding's **class** is its root cause; its **severity** is still what gates the verdict —
316
+ and a per-state policy that re-grades a severity changes the **gate**, while triage only changes how the
317
+ **same** result is **explained**. In short: triage tells the broker *what to fix first*; it never
318
+ decides *whether they pass*.
319
+
320
+ ### Triage does NOT change the honest custodian/CPA posture
321
+
322
+ A legible FAIL is still a FAIL of a tool that **AIDS** reconciliation — the triage headline names the
323
+ likeliest first fix, it does **not** certify anything. The broker remains the **legal trust-account
324
+ custodian**, a **PASS does not certify legal compliance**, and a qualified **CPA** must still review the
325
+ packet, exactly as the disclaimer at the top of this document states. The triage classes are a
326
+ **DRAFT** legibility aid layered over the **same DRAFT** severity policy a CPA signs — naming a finding
327
+ `out_of_trust` is the engine's first-contact diagnosis, not a legal determination. The standing
328
+ **DRAFT / NOT LEGAL ADVICE** posture therefore applies to the triage headline **verbatim**:
329
+
330
+ > **DRAFT / NOT LEGAL ADVICE.** The policies that SHIP with TrustLedger
331
+ > (`trustledger/fixtures/policy/*.json`) are **DRAFT skeletons**, not legal advice and **not a claim of
332
+ > regulatory compliance**. The baseline reproduces the built-in defaults verbatim; the example state
333
+ > file carries a **PLACEHOLDER** citation. A qualified **CPA and/or counsel must review and SIGN** the
334
+ > per-state severity mapping and its statute citations for the actual jurisdiction before the gate is
335
+ > relied on. Selecting a policy does **not** make a packet legal advice and does **not** discharge the
336
+ > broker's duty as the responsible legal custodian of trust funds. (STRATEGY.md › P-5 #1/#2.)
337
+
338
+ Triage adds **no** new human gate: it explains the verdict the existing gate already produces. It is a
339
+ pure, auto-built legibility layer over the same engine — there is **no** new `needs-human` item beyond
340
+ the P-5 design-partner / CPA sign-off the rest of this document already tracks.
341
+
342
+ ---
343
+
344
+ ## The value-proof: `vh trust value-proof` — the pilot's willingness-to-pay instrument
345
+
346
+ The two-month design-partner pilot (P-5 #3, runbook in [`docs/PILOT.md`](PILOT.md)) asks one question
347
+ the whole sale rests on: **"is this worth paying for ON MY data?"** A demo on our fixtures cannot answer
348
+ it; only the partner's **own already-closed period** can. `vh trust value-proof` is the **measured
349
+ instrument** that turns that question into a number — it runs a month the broker **already reconciled by
350
+ hand and signed off on** through the **same** reconcile gate, diffs the gate's findings against that
351
+ manual close, and prints **one of three outcomes** plus the exact dollars the manual close let through:
352
+
353
+ ```
354
+ vh trust value-proof <bank> <ledger> <rentroll> [--state/--policy <f> --license <f> --vendor <0xaddr>]
355
+ [--asserted-flagged] [--asserted-net <dollars>] [--period <label>] [--json]
356
+ ```
357
+
358
+ It is a **pure, offline, read-only lens** over the gate's already-computed verdict
359
+ (`trustledger/valueproof.js` › `valueProof`, surfaced by `trustledger/cli.js` › `cmdValueProof`): every
360
+ count and dollar figure it prints is read **verbatim** off the period's reconciliation triage rollup —
361
+ the **same** numbers `reconcile --json` / the HTML packet show. It **writes nothing** (no packet, no
362
+ seal, no file), leaves the cwd untouched, and adds **no** new finding, severity, verdict, or exit-code
363
+ rule of its own. It is a presentation lens for a go-to-market conversation, **not** a second opinion on
364
+ the books.
365
+
366
+ ### The three outcomes (and their exit codes)
367
+
368
+ The outcome is decided by the **most-urgent root-cause class** the gate found (the same `out_of_trust →
369
+ data_completeness → needs_review → timing` priority triage uses), and the exit code maps it so a pilot
370
+ can gate on it in CI:
371
+
372
+ | Outcome | Exit | What it means for the pilot |
373
+ | --- | --- | --- |
374
+ | **`out_of_trust_missed`** | `3` | **The WTP case.** The gate found ≥1 genuine out-of-trust finding (a shortage / commingling / conversion) the manual close called clean. The headline names the **count + total abs-cents dollar impact** the manual close **let through** — the concrete figure that justifies the subscription. |
375
+ | **`data_gap_only`** | `4` | The gate found **NO** out-of-trust finding but **could not fully reconcile/classify** the data (a `data_completeness` gap). Stated honestly as a data-shape gap to **fix and re-run** — **not (yet) evidence the money is gone**, and **never** framed as a missed shortage. (A distinct exit code from a real FAIL so a pipeline can tell "fix my data" from "out of trust.") |
376
+ | **`clean_confirmed`** | `0` | The gate **AGREES** with the manual close: no out-of-trust finding and the data reconciled. The broker now has a **signed, independent, one-command confirmation of a clean trust account** to hand their auditor — the recurring deliverable that earns renewal even in a clean month. |
377
+
378
+ `2` is a usage error and `1` is an IO/input error, matching the rest of the `vh trust` family. The
379
+ manual-close baseline (`--asserted-flagged` flips it from the default "asserted CLEAN" to "the manual
380
+ close also flagged it") drives **only** the `agrees` flag — it **never** changes the outcome, a number,
381
+ or the exit code, which ride the **gate's** verdict, not the broker's assertion. `--asserted-net
382
+ <dollars>` echoes the manual close's signed-off net figure as an **annotation only**; it changes nothing.
383
+
384
+ ### How to run it (the pilot step)
385
+
386
+ The instrument runs on the partner's **real** historical month, through the **same** verdict-shaping
387
+ inputs the production gate threads — `--state`/`--policy` (the licensed per-state policy that can flip a
388
+ PASS to a FAIL), `--prior-close` (the roll-forward), `--license`/`--vendor` (the paid-policy gate), and
389
+ `--map`/`--map-file` (non-default headers). So the value-proof is **genuinely the same verdict path** the
390
+ paying broker's own gate runs — never a narrower baseline-only path that could confidently print "clean
391
+ confirmed" on a period the licensed gate FAILs. When a policy/prior-close **escalates** a finding the
392
+ type-based class treats as benign, the human output names the gate's **FAIL** verdict and a
393
+ **`gate verdict: FAIL` / `ESCALATED a finding` / NOT a clean confirmation** note, exiting non-zero — it
394
+ never silently inverts the claim.
395
+
396
+ ```
397
+ $ vh trust value-proof bank-2026-05.csv ledger-2026-05.csv rentroll-2026-05.csv --period 2026-05
398
+ outcome: out_of_trust_missed
399
+ gate verdict: FAIL (the production reconcile gate's verdict for these inputs)
400
+ headline: Your manual close signed this period off as clean, but the gate found 1 out-of-trust
401
+ finding totaling $1,000.00 the manual close let through. Restore the trust account …
402
+
403
+ $ echo $?
404
+ 3
405
+ ```
406
+
407
+ `--json` carries the full structured result (`outcome`, `code`, `gateVerdict`, `policyEscalated`,
408
+ `missedFindings` `{count, absImpact, byClass}`, `headline`, and the `caveat`) so a pipeline gates on the
409
+ data. Drive the **same** files through `reconcile --json` and `value-proof --json` and the triage
410
+ numbers are **identical** — that equivalence is the whole point and is pinned by the test suite.
411
+
412
+ ### What the value-proof DOES and DOES NOT mean
413
+
414
+ > **The value-proof COMPARES the gate to the broker's manual close — it does NOT certify a jurisdiction
415
+ > or constitute legal advice.** A value-proof run proves **one** thing, precisely: on the broker's own
416
+ > closed period, through the **same** engine path the real `reconcile` exit uses, **what the gate found
417
+ > that the manual close did not**, quantified in dollars and partitioned by the existing triage
418
+ > root-cause classes. It is a **comparison of the gate against the manual close**, surfaced so a pilot
419
+ > can read the value as a measured number instead of a relational hunch. It does **NOT** certify that any
420
+ > **state's** trust-fund rules are satisfied, does **NOT** certify a jurisdiction's severity mapping
421
+ > (that is the still-DRAFT per-state policy a CPA must sign — see **The per-state policy layer**), does
422
+ > **NOT** audit the broker's books, and does **NOT** constitute legal, accounting, or audit advice. An
423
+ > `out_of_trust_missed` result is the engine's diagnosis that the gate found a finding the manual close
424
+ > let through — **not** a legal determination that the account is out of trust in that jurisdiction. The
425
+ > custodian/CPA posture below is unchanged: TrustLedger **aids** reconciliation, the broker remains the
426
+ > **responsible legal trust-account custodian**, a **PASS does not certify legal compliance**, and a
427
+ > qualified **CPA** must still review the packet. The value-proof replaces "their willingness to keep
428
+ > using it is the WTP signal" with "run one command on your own month and read the dollars the gate
429
+ > caught that your manual close missed" — it does not replace the human review it makes faster.
430
+
431
+ Because the value-proof only ever reads the **existing** verdict, it adds **no** new behaviour and **no**
432
+ new human gate: every number it reports is the gate's own. It rides the **same** DRAFT / NOT-LEGAL-ADVICE
433
+ posture the rest of this document carries, **verbatim**:
434
+
435
+ > **DRAFT / NOT LEGAL ADVICE.** The policies that SHIP with TrustLedger
436
+ > (`trustledger/fixtures/policy/*.json`) are **DRAFT skeletons**, not legal advice and **not a claim of
437
+ > regulatory compliance**. The baseline reproduces the built-in defaults verbatim; the example state
438
+ > file carries a **PLACEHOLDER** citation. A qualified **CPA and/or counsel must review and SIGN** the
439
+ > per-state severity mapping and its statute citations for the actual jurisdiction before the gate is
440
+ > relied on. Selecting a policy does **not** make a packet legal advice and does **not** discharge the
441
+ > broker's duty as the responsible legal custodian of trust funds. (STRATEGY.md › P-5 #1/#2.)
442
+
443
+ The value-proof is a **DRAFT** WTP-measurement lens layered over the **same DRAFT** severity policy a CPA
444
+ signs — there is **no** new `needs-human` item beyond the P-5 design-partner / CPA sign-off the rest of
445
+ this document already tracks.
446
+
447
+ ---
448
+
449
+ ## Exceptions and their severities
450
+
451
+ Every difference the pipeline finds is emitted as a classified exception. The severities are:
452
+
453
+ - **INFO** — a benign, self-clearing reconciling item (deposit in transit, outstanding check, generic
454
+ timing). Expected; does not fail the gate on its own.
455
+ - **WARNING** — needs a human eye but may be legitimate (an NSF reversal, an owner draw **within that
456
+ owner's own contributed capital — `owner_draw`**, an unreconciled bank/book line).
457
+ - **ERROR** — the trust account is **out of trust**: a real finding that FAILs the gate (an
458
+ un-segregated security deposit, the sub-ledger out of balance vs. the book, **an individual
459
+ beneficiary's own ledger negative — `negative_tenant_ledger`**, **an owner draw that exceeds that
460
+ owner's own contributed capital — `owner_overdraw`, where the excess is other beneficiaries' trust
461
+ money**, adjusted bank ≠ book).
462
+
463
+ > **An owner draw splits into a benign part and an out-of-trust part — two distinct findings.** A
464
+ > *benign* owner draw (one paid from that owner's **own** contributed capital) is the `owner_draw`
465
+ > **WARNING** above — a human should confirm it, but it does not FAIL the gate. The **excess** of a draw
466
+ > **beyond** that owner's own contributed capital is the separate **`owner_overdraw` ERROR**: that excess
467
+ > is paid out of *other* beneficiaries' trust money (a conversion of trust funds), so it is out of trust
468
+ > and FAILs the gate. The earlier wording that described an owner draw as **only** a WARNING is corrected
469
+ > here: the benign draw stays a WARNING, but the over-capital **excess** is an ERROR. See **`owner_overdraw`**
470
+ > under *The policy file schema* below for the full per-account rule and its control-account boundary
471
+ > (`trustledger/reconcile.js` › `classifyOwnerDraws`).
472
+
473
+ > **The severity mapping is policy, not law.** The built-in baseline (security-deposit-not-segregated =
474
+ > ERROR, NSF reversal = WARNING, a benign `owner_draw` = WARNING but an over-capital `owner_overdraw` =
475
+ > ERROR, …) is a sensible starting point but is
476
+ > **state- and CPA-dependent**. It is the default *when you select no policy*; a reviewed per-state
477
+ > policy file overrides it (see **The per-state policy layer** below). The shipped policies are
478
+ > **DRAFTS, not legal advice** — a CPA/counsel must review and sign the per-state mapping before you
479
+ > rely on it (STRATEGY.md › P-5 #1/#2). Treat any classification as a draft control, not a settled
480
+ > legal determination.
481
+
482
+ ---
483
+
484
+ ## The per-state policy layer
485
+
486
+ What counts as **out of trust** (an ERROR that FAILs the gate) versus **needs a human eye** (a WARNING)
487
+ is not a universal fact — it is a function of the **state's trust-account statute**. One state makes an
488
+ owner draw against tenant money a per-se ERROR; another treats an NSF reversal as a mere WARNING until
489
+ the deposit is cured. So TrustLedger does not bake one severity table in as if it were law. The baseline
490
+ is a **default**, and a **per-state policy file** overrides it.
491
+
492
+ A policy is **data, not code**: a small, versioned, strictly-validated JSON file. The engine consumes it
493
+ unchanged — so producing a defensible per-state control is a **fill-in-the-table** task for a qualified
494
+ human, not a from-scratch engineering job.
495
+
496
+ > **DRAFT / NOT LEGAL ADVICE.** The policies that SHIP with TrustLedger
497
+ > (`trustledger/fixtures/policy/*.json`) are **DRAFT skeletons**, not legal advice and **not a claim of
498
+ > regulatory compliance**. The baseline reproduces the built-in defaults verbatim; the example state
499
+ > file carries a **PLACEHOLDER** citation. A qualified **CPA and/or counsel must review and SIGN** the
500
+ > per-state severity mapping and its statute citations for the actual jurisdiction before the gate is
501
+ > relied on. Selecting a policy does **not** make a packet legal advice and does **not** discharge the
502
+ > broker's duty as the responsible legal custodian of trust funds. (STRATEGY.md › P-5 #1/#2.)
503
+
504
+ ### The policy file schema
505
+
506
+ A policy file is a single JSON object. Every field:
507
+
508
+ | Field | Required | Type | Meaning |
509
+ | --- | --- | --- | --- |
510
+ | `schemaVersion` | **yes** | integer | Must equal the build's supported version (currently **1**). Any other value is a hard, named error — never silently accepted. Bumped only on an incompatible change. |
511
+ | `state` | **yes** | non-empty string | A **human label** for the jurisdiction/policy (e.g. `"California"`). Carried into the packet so it names which policy governed the run. Also one of the two keys `--state <code>` resolves against. |
512
+ | `severities` | **yes** | object map | The override table: `exceptionType -> severity`. Each **key** must be a legal exception type and each **value** one of `"info"`, `"warning"`, `"error"`. An unknown type or a bad severity is a hard error. A type **absent** from the map keeps its baseline severity. |
513
+ | `citations` | no | object map | `exceptionType -> statute/rule string` (a **citation/label**, free text). Carried into the packet next to each overridden row so the control is grounded in the rule it rests on. You may cite **only** a type you also override in `severities`; citing a rule you do not apply is rejected as misleading in an audit. |
514
+ | `toleranceCents` | no | non-negative integer | The tie-out tolerance, in **integer cents**, this policy imposes. When present it **takes precedence** over the CLI `--tolerance-cents` / the default `0` (a policy that names an exact-tie rule should not be silently loosened by a CLI flag). |
515
+
516
+ `severities` keys and `citations` keys are **citations/labels of policy** — the legal content a human
517
+ fills in and a CPA signs. `state` is a **label**. `schemaVersion`/`toleranceCents` are mechanical. The
518
+ shipped fixtures additionally carry a `_DISCLAIMER` string; it is ignored by the engine (any extra
519
+ top-level key is) and exists only to keep the DRAFT posture attached to the file itself.
520
+
521
+ The **legal exception types** (the allowed `severities`/`citations` keys) are not re-declared in the
522
+ policy module — they are derived from the engine's own `EXCEPTION` enum, so a typo'd type is a
523
+ validation error rather than a silently-ignored key. They are:
524
+
525
+ ```
526
+ outstanding_deposit outstanding_check timing
527
+ nsf_reversal owner_draw owner_overdraw
528
+ security_deposit_segregation ambiguous_deposit
529
+ unreconciled_bank unreconciled_book
530
+ subledger_out_of_balance negative_tenant_ledger bank_book_mismatch
531
+ continuity_break
532
+ ```
533
+
534
+ `ambiguous_deposit` is raised for a book deposit whose beneficiary type cannot be
535
+ determined — a deposit-scale inflow that calls itself a "deposit" but carries no
536
+ recognizable keyword (not clearly rent, an owner contribution, or a labeled
537
+ security deposit) and is not an explicitly-labeled receipt. Its default severity
538
+ is `warning` (it MIGHT be an un-segregated security deposit hiding as a generic
539
+ deposit, so a human must look — but absent a security-deposit signal it is not
540
+ auto-escalated to the out-of-trust `error` a confirmed unsegregated deposit
541
+ gets); like every other type, a per-state policy MAY re-grade it. The
542
+ silent-false-pass hazard it exists to close, the WARNING default + the
543
+ explicit-label escape valve, and grading it to ERROR per state are documented in
544
+ **Why `ambiguous_deposit` exists: the silent-false-pass hazard** below.
545
+
546
+ `continuity_break` is raised only when a run chains from a prior period's close
547
+ (`--prior-close`) and this period's opening does not roll forward penny-exact from
548
+ that prior period's signed ending. Its default severity is `error` (a broken
549
+ roll-forward means the books do not actually continue from the signed prior
550
+ period), and — like every other type — a per-state policy MAY re-grade it (e.g. a
551
+ state that treats a documented timing roll-forward difference as a `warning`).
552
+
553
+ `negative_tenant_ledger` is raised when an **individual** beneficiary's own
554
+ sub-ledger balance is negative (beyond `toleranceCents`) — the broker is holding
555
+ *less than zero* in trust for that person, because their money was spent or used
556
+ to cover another beneficiary's shortfall. It is **orthogonal** to
557
+ `subledger_out_of_balance`: the pooled SUM of all sub-ledgers can tie perfectly to
558
+ the book while one tenant's surplus masks another tenant's deficit, so this check
559
+ fires per-beneficiary **independently of whether the SUM ties** (both can fire at
560
+ once). Control/sink accounts (an owner's-own-funds line, an
561
+ `escrow`/`segregated`/`trust` sink, an `operating`/`reserve`/`suspense` control
562
+ line) are excluded — their negative balance is structural, not a tenant shortage.
563
+ Its default severity is `error` (a negative individual ledger is out of trust on
564
+ its own); like every other type, a per-state policy MAY re-grade it.
565
+
566
+ `owner_overdraw` is raised when an **owner/control account** draws MORE than that
567
+ account's OWN contributed capital in this period — i.e. the owner paid themselves
568
+ out of *other* beneficiaries' trust money (a conversion of trust funds, the single
569
+ most-prosecuted residential-PM trust violation). It is the precise **inverse** of
570
+ the `negative_tenant_ledger` control-account exclusion above: that exclusion keeps
571
+ ignoring an owner's negative *within* its contributed capital (the owner
572
+ legitimately deploying their own funds), and `owner_overdraw` catches only the
573
+ negative *beyond* it. Per owner account (keyed by the draw's party), the engine
574
+ sums the account's own positive book inflows (its **contributed capital**, `C`) and
575
+ its owner-draw lines (`D`), and flags the EXCESS `D − C` — bounded by how negative
576
+ the account actually went so it never claims more tenant money than is genuinely
577
+ missing, and honoring `toleranceCents`. It fires **only** when the account
578
+ established an in-period contribution basis (`C > 0`); absent any in-period
579
+ contribution, the sub-ledger negative is treated as legitimate **opening** owner
580
+ capital being deployed (the same control-account boundary the exclusion above
581
+ respects) and is not second-guessed from a name. Crucially, this fires **even when
582
+ the pooled three-way SUM ties out** — the owner's negative control bucket can
583
+ absorb the overdraw so `tiesOut` stays `true`, yet the account is out of trust.
584
+ The benign part of the draw — the portion **within** contributed capital — stays
585
+ the `owner_draw` WARNING; only the over-capital **excess** is this ERROR. Its
586
+ default severity is `error`; like every other type, a per-state policy MAY
587
+ re-grade it (`trustledger/reconcile.js` › `classifyOwnerDraws`).
588
+
589
+ **How a line is recognized as a control account (and its failure mode).** Two
590
+ signals exclude a negative line from `negative_tenant_ledger`, in priority order:
591
+
592
+ 1. **A structured `controlAccount: true` marker on the sub-ledger row
593
+ (authoritative).** Set it on the rent-roll row(s) for that party. This is a
594
+ deliberate assertion by the producer of the data — it is preferred over any
595
+ guess and excludes the line regardless of what its name reads like (the same
596
+ way an explicit deposit label beats a free-text guess for `ambiguous_deposit`).
597
+ 2. **A leading-token name heuristic (fallback, used only without a marker).** A
598
+ line is treated as a control designation when the **first** whole-word token of
599
+ its party name is `owner`/`owners`/`escrow`/`segregated`/`trust`/`operating`/
600
+ `reserve`/`suspense` — i.e. the name leads with the account designation, like
601
+ `Owner Acme` or `Escrow`. This is word-bounded, so an ordinary surname that
602
+ merely contains a control token (`Owens`, `Crowell`) is **not** excluded.
603
+
604
+ **Failure mode you must know:** the name heuristic only looks at the **leading**
605
+ token, so a real beneficiary whose name contains a control word in a *non-leading*
606
+ position — `Smith (OWNER)`, `Jones Family Trust`, `Tenant 12 Reserve St` — IS
607
+ correctly flagged when negative (it is not treated as a control account). But the
608
+ heuristic **cannot** tell a genuine company beneficiary whose name *leads* with a
609
+ control word (e.g. `Operating Co LLC`) apart from an `Operating` control account,
610
+ so such a line is excluded by name alone and a negative balance on it would not
611
+ surface. To protect a beneficiary whose name leads with a control word — and to
612
+ mark any control account unambiguously rather than relying on its name — set the
613
+ structured `controlAccount` marker, which is authoritative over the name guess.
614
+ The precomputed `{ party: cents }` balance-map form has no per-key slot for the
615
+ marker, so a control account supplied that way must rely on the leading-token
616
+ name (or be supplied as rows).
617
+
618
+ ### Why `ambiguous_deposit` exists: the silent-false-pass hazard
619
+
620
+ A keyword-only security-deposit detector has a quiet, dangerous failure mode. The
621
+ segregation check that produces the out-of-trust `security_deposit_segregation`
622
+ ERROR only fires when a book inflow **looks like** a security deposit — it matches
623
+ a `security deposit` / `damage deposit` / `deposit held` keyword. That keyword
624
+ match is the **only** signal. So a real, un-segregated security deposit that a
625
+ bookkeeper recorded as a bare **`Deposit - 12B Smith`** — no "security", no
626
+ "damage", just the generic word *deposit* and a tenant — **never trips the
627
+ detector**. It is not rent, not an owner contribution, not a labeled security
628
+ deposit; the keyword-only check simply says nothing, the three balances still tie
629
+ out (the money cleared and sits on the sub-ledger), and the gate **PASSes**. That
630
+ is a **silent false pass**: a possibly out-of-trust deposit slips through *because
631
+ it was mislabeled*, and a keyword-only detector cannot tell the difference between
632
+ "this is definitely fine" and "I could not classify this." The broker gets a green
633
+ PASS that does not mean what they think it means.
634
+
635
+ `ambiguous_deposit` closes that gap by making **"I could not classify this"** a
636
+ **LOUD, gradable finding** instead of silence. It is raised for a book deposit
637
+ whose beneficiary type cannot be determined — a deposit-scale inflow that calls
638
+ itself a "deposit" (the word, or `kind === "deposit"`), carries an attributed
639
+ party, but offers **no recognizable purpose keyword** (it is not clearly rent, an
640
+ owner contribution, a refund, a fee, a transfer, … — the closed
641
+ `RECOGNIZED_DEPOSIT_PURPOSE` allowlist) **and** is not an explicitly-labeled
642
+ receipt. The predicate is `trustledger/reconcile.js` › `isAmbiguousDeposit` (pure:
643
+ free-text classification only, no fs/http/clock). A row that already matches the
644
+ security-deposit keyword is handled by the segregation ERROR and is **not**
645
+ re-flagged here, so the same row is never double-counted.
646
+
647
+ **The WARNING default + the explicit-label escape valve.** The default severity is
648
+ `warning`, not `error`. The reasoning is deliberate: an ambiguous deposit *might*
649
+ be an un-segregated security deposit hiding as a generic deposit (so a human must
650
+ look — silence would be the false pass above), but absent any security-deposit
651
+ signal it is **not** auto-escalated to the out-of-trust `error` a *confirmed*
652
+ unsegregated deposit gets. A WARNING does not by itself FAIL the gate, so a firm
653
+ whose three balances tie out and whose only finding is one ambiguous deposit still
654
+ PASSes — it is **not over-FAILed** for a labeling gap. The escape valve is for the
655
+ producer who already knows what the row is: an **explicit per-record label**
656
+ suppresses the finding (`hasExplicitDepositLabel`). Any **one** of these markers
657
+ suffices — `kind: "rent"` (an explicit rent receipt), a non-empty `depositType`
658
+ (the beneficiary type was stated), `ambiguous: false` (the caller asserts it is
659
+ determined), or `expected: true` (a known/expected line). A marker is a
660
+ deliberate, structured assertion by whoever produced the row — distinct from the
661
+ engine *guessing* from free text — so it is authoritative and the deposit is no
662
+ longer flagged. This keeps a genuinely-unlabeled deposit LOUD while letting an
663
+ exporter that knows its own data turn the finding off cleanly, without weakening
664
+ the detector for everyone else.
665
+
666
+ **Grading it to ERROR is a per-state CPA decision, via the EXISTING policy layer.**
667
+ Whether an unclassifiable deposit should be a mere WARNING or a hard, out-of-trust
668
+ ERROR is **not** a universal fact — it depends on the state's trust-account
669
+ statute, exactly like every other severity. So TrustLedger does **not** bake the
670
+ escalation in. `ambiguous_deposit` is one of the **legal exception types** above, so
671
+ a per-state policy MAY re-grade it through the **same data-not-code** override
672
+ mechanism every other type uses: a reviewed policy with
673
+ `severities.ambiguous_deposit: "error"` flips the verdict on the *same* files (a
674
+ clean-tying account with one ambiguous deposit goes PASS → FAIL, exit `0` → `3`),
675
+ with its statute `citation` carried into the packet. The bundled
676
+ `ambiguous-deposit-example` draft (`vh trust reconcile --state ambiguous-deposit-example`)
677
+ demonstrates exactly this escalation with a **PLACEHOLDER** citation — illustrative
678
+ only, not a real jurisdiction. Because this rides the existing policy layer, the
679
+ **DRAFT / NOT LEGAL ADVICE** posture from that section applies **verbatim**:
680
+
681
+ > **DRAFT / NOT LEGAL ADVICE.** The policies that SHIP with TrustLedger
682
+ > (`trustledger/fixtures/policy/*.json`) are **DRAFT skeletons**, not legal advice and **not a claim of
683
+ > regulatory compliance**. The baseline reproduces the built-in defaults verbatim; the example state
684
+ > file carries a **PLACEHOLDER** citation. A qualified **CPA and/or counsel must review and SIGN** the
685
+ > per-state severity mapping and its statute citations for the actual jurisdiction before the gate is
686
+ > relied on. Selecting a policy does **not** make a packet legal advice and does **not** discharge the
687
+ > broker's duty as the responsible legal custodian of trust funds. (STRATEGY.md › P-5 #1/#2.)
688
+
689
+ So deciding that `ambiguous_deposit` should hard-FAIL in a given state is a
690
+ **fill-in-the-table** task for a qualified human (set `severities.ambiguous_deposit`
691
+ to `error` with the statute citation, have a CPA/counsel sign it) — **no engine
692
+ change**, and **no new `needs-human` item** beyond the per-state policy sign-off
693
+ P-5 #2 already tracks.
694
+
695
+ ### Selecting a policy: `--state` vs `--policy`
696
+
697
+ Exactly one selection mechanism, or none:
698
+
699
+ | You pass | What happens |
700
+ | --- | --- |
701
+ | *(neither flag)* | The run uses the **built-in baseline** severities (no policy). This path is **byte-for-byte** today's behaviour — same verdict, same packet. |
702
+ | `--state <code>` | Resolve a **bundled** draft policy (`trustledger/fixtures/policy/<code>.json`) by its filename code **or** its `state` label (case-/punctuation-insensitive). An unknown code is a **usage error** (exit `2`) that lists the bundled codes. |
703
+ | `--policy <file>` | Read an **explicit** policy file from a path you supply. A malformed or unreadable file is a **usage error** (exit `2`) — a bad flag value, not a data-file IO error. |
704
+ | **both** `--state` and `--policy` | Ambiguous → **usage error** (exit `2`). They are mutually exclusive. |
705
+
706
+ The bundled policies that ship today (`vh trust reconcile --state <code>`):
707
+
708
+ | Code | `state` label | What it does |
709
+ | --- | --- | --- |
710
+ | `baseline` | `BASELINE (built-in defaults)` | Reproduces the built-in defaults verbatim — selecting it is identical to selecting nothing. A reference skeleton to copy. |
711
+ | `ca-example` | `EXAMPLE-STATE (illustrative override)` | **ILLUSTRATIVE ONLY.** Escalates `nsf_reversal` from the baseline WARNING to ERROR, with a **PLACEHOLDER** citation, to demonstrate the override mechanism. Not a real jurisdiction. |
712
+ | `ambiguous-deposit-example` | `EXAMPLE-STATE (ambiguous-deposit hard-fail)` | **ILLUSTRATIVE ONLY.** Escalates `ambiguous_deposit` (a book deposit whose beneficiary type cannot be determined) from the baseline WARNING to ERROR, with a **PLACEHOLDER** citation, so an unclassifiable deposit becomes a hard FAIL until it is classified. Not a real jurisdiction. |
713
+ | `negative-tenant-ledger-example` | `EXAMPLE-STATE (negative-ledger re-grade)` | **ILLUSTRATIVE ONLY.** Re-grades `negative_tenant_ledger` (an individual beneficiary whose own trust sub-ledger is negative) from the baseline ERROR **down** to WARNING, with a **PLACEHOLDER** citation — showing the re-grade is possible by state with **no schema change** (one entry in the existing `severities` map). A negative individual ledger is out of trust in most jurisdictions, so the de-escalation is illustrative, **not** a recommendation. Not a real jurisdiction. |
714
+ | `owner-overdraw-example` | `EXAMPLE-STATE (owner-overdraw re-grade)` | **ILLUSTRATIVE ONLY.** Re-grades `owner_overdraw` (an owner account that drew MORE than its own contributed capital, paying itself out of other beneficiaries' trust money) from the baseline ERROR **down** to WARNING, with a **PLACEHOLDER** citation — showing the re-grade is possible by state with **no schema change** (one entry in the existing `severities` map). Paying an owner out of tenant or security-deposit money is a conversion of trust funds, so the de-escalation is illustrative, **not** a recommendation. Not a real jurisdiction. |
715
+
716
+ ### How PASS now depends on the selected policy
717
+
718
+ PASS is decided as **`tiesOut && error-count == 0`**. Because the policy supplies the severities, **the
719
+ selected policy is part of the PASS decision**: escalating a finding to ERROR can flip a PASS to a FAIL,
720
+ and de-escalating an ERROR can flip a FAIL to a PASS, on the *same* three files. The packet always names
721
+ the governing policy and appends an extra disclaimer line stating that the verdict reflects that
722
+ selected (still-DRAFT) policy. With no policy selected, PASS depends only on the built-in baseline,
723
+ exactly as before.
724
+
725
+ ### Worked example: the verdict flips under a state override
726
+
727
+ Take a month whose files **tie out** and contain one `nsf_reversal`. Under the **baseline**, that NSF is
728
+ a WARNING, so the gate **PASSes**:
729
+
730
+ ```
731
+ $ vh trust reconcile bank.csv ledger.csv rentroll.csv; echo "exit=$?"
732
+ PASS: three-way reconciliation tie out (...); 1 exception(s) [0 error, 1 warning, 0 info]
733
+ exit=0
734
+ ```
735
+
736
+ Now select a state whose statute makes that NSF reversal a hard, out-of-trust finding. The
737
+ `ca-example` draft escalates `nsf_reversal` to ERROR, so on the **identical files** the verdict **flips
738
+ to FAIL** and the exit code becomes `3`:
739
+
740
+ ```
741
+ $ vh trust reconcile bank.csv ledger.csv rentroll.csv --state ca-example; echo "exit=$?"
742
+ FAIL: ... ; 1 exception(s) [1 error, 0 warning, 0 info]
743
+ exit=3
744
+ ```
745
+
746
+ Same input, different verdict — *because the policy changed, not the numbers.* The packet for the second
747
+ run names `EXAMPLE-STATE (illustrative override)` as the governing policy and shows the escalated row's
748
+ citation, so an auditor can see **which rule** drove the FAIL. (`--policy ./my-state.json` does the same
749
+ with an explicit file.) This is exactly why the per-state mapping must be **reviewed and signed by a
750
+ CPA/counsel** before it gates a real broker's close: the policy *is* the legal determination the verdict
751
+ rests on.
752
+
753
+ ---
754
+
755
+ ## Period-close continuity (chaining one month to the next)
756
+
757
+ A three-way trust reconciliation is a **monthly** ritual. Each month's reconciled **ending** balances
758
+ become the **next** month's **opening** balances — the *roll-forward*. If May closes at a bank balance of
759
+ $3,300.00, June **must** open at exactly $3,300.00; any other opening means a period was skipped, edited,
760
+ or re-keyed, and the chain of custody over the trust money is broken. A fat-fingered opening silently
761
+ shifts every balance and can flip PASS↔FAIL — so the tool makes the roll-forward an explicit,
762
+ machine-checked artifact.
763
+
764
+ Two flags drive the chain:
765
+
766
+ - **`--emit-close <file>`** — at the end of a run, write a small JSON **close artifact** that records this
767
+ period's ending balances (plus enough context to chain and detect tampering).
768
+ - **`--prior-close <file>`** — at the start of the next run, read the prior period's close artifact, **seed
769
+ this run's opening** from its ending, and run a **continuity check** that the roll-forward is
770
+ penny-exact.
771
+
772
+ Both are **additive**: with neither flag the engine behaves byte-for-byte as before (no `continuity`
773
+ metadata, no `continuity_break` exception — see **Additivity** below).
774
+
775
+ ### The close-artifact schema
776
+
777
+ A close artifact is a single JSON object (`trustledger/close.js` is the single source of truth: pure
778
+ `buildClose` / `readClose` / `validateClose`). Every field:
779
+
780
+ | Field | Type | What it is | Trust class |
781
+ | --- | --- | --- | --- |
782
+ | `schemaVersion` | string `"trustledger.period-close/v1"` | Pins the artifact shape. **Any other value is a hard, named `CloseError`** — a close from a future/older tool is never silently coerced. | mechanical |
783
+ | `period` | string \| null | The human period label this close came from (e.g. `"2026-05"`), or `null` if the run carried no `--period`. | **hint / label** |
784
+ | `reportDate` | string `"YYYY-MM-DD"` | The report date of the run that emitted the close. | **hint / label** |
785
+ | `opening` | `{ bank, book }` integer cents | The opening balances **that run** used. | **hint** (asserted) |
786
+ | `ending` | `{ bank, book }` integer cents | The **closing** bank/book balances — the numbers the next period must open at. The roll-forward is checked against these. | **hint** (asserted) |
787
+ | `subledger` | integer cents | The sub-ledger total at close. | **hint** (asserted) |
788
+ | `tiesOut` | boolean | Whether the emitting run's three balances tied out. | **hint** (asserted verdict) |
789
+ | `pass` | boolean | The emitting run's PASS/FAIL verdict (tiesOut AND zero error-severity findings). | **hint** (asserted verdict) |
790
+ | `inputs` | `{ bankRecords, bookRecords, rentrollRecords }` non-negative integers | The input record counts the emitting run saw — context, and part of the digest. | **hint** |
791
+ | `inputsDigest` | 64-char lowercase hex | A **SHA-256 digest** over a canonical, order-stable projection of the fields above (via the vendored pure-JS SHA-256 in `trustledger/lib/sha256-vendored.js` — **no new dependency**, browser-portable, and proven byte-identical to Node's built-in `crypto` by test). It **binds** the close to the summary it carries, so a hand-edited field is detectable. | **digest** |
792
+
793
+ Every value-bearing field is an **asserted hint** (a convenience the next run re-derives), `schemaVersion`
794
+ is **mechanical**, and `inputsDigest` is a **convenience integrity tag** over the *summary the close
795
+ carries* — **not** a cryptographic proof of the underlying source files (those are the authoritative
796
+ inputs and are re-read on the next reconciliation), and **not** a signature. All money is **integer cents**
797
+ (no floats); a non-integer-cents balance is a hard `CloseError`. The shipped artifact carries no clock or
798
+ randomness beyond the explicit `reportDate`, so `buildClose` is byte-deterministic for a given model.
799
+
800
+ ### The `--prior-close` / `--emit-close` flow
801
+
802
+ ```
803
+ month 1: vh trust reconcile bank1 ledger1 rent1 --period 2026-05 --emit-close month1.json
804
+ -> reconciles month 1, writes month1.json (ending bank/book/sub recorded)
805
+
806
+ month 2: vh trust reconcile bank2 ledger2 rent2 --period 2026-06 --prior-close month1.json --emit-close month2.json
807
+ -> seeds opening from month1.json's ending, checks the roll-forward,
808
+ reconciles month 2, writes month2.json (so month 3 can chain in turn)
809
+ ```
810
+
811
+ On a `--prior-close` run:
812
+
813
+ 1. The prior close is **read and strictly validated** (`close.readClose`). A malformed or
814
+ structurally-invalid close, or a missing file, is a **usage error (exit `2`)** — a bad flag value, not
815
+ a data-file IO error. (`error: invalid --prior-close …` / `error: cannot read --prior-close …`.)
816
+ 2. This run's **opening is seeded** from the prior close's `ending` (bank ← `ending.bank`, book ←
817
+ `ending.book`), **unless** you also pass an explicit `--opening-bank` / `--opening-book`. An explicit
818
+ opening that **disagrees** with the prior ending is **honored and noted on stderr** (`note: --opening-bank
819
+ … overrides the prior close's ending bank balance …`) — and the continuity check below then flags the
820
+ resulting gap, so a chain-breaking override surfaces as a `CONTINUITY_BREAK` rather than silently. An
821
+ explicit opening that **agrees** seeds cleanly with no note.
822
+ 3. The **continuity check** (`close.checkContinuity`) compares the **opening actually used** against the
823
+ prior `ending`, **penny-exact, with zero tolerance** (a roll-forward must be exact — a one-cent drift is
824
+ a real gap, not noise). It returns `{ ok, bankGap, bookGap }` where `bankGap = opening.bank −
825
+ priorEnding.bank` (signed; positive means this period opened **higher** than the prior closed).
826
+
827
+ ### The continuity check and `CONTINUITY_BREAK`
828
+
829
+ When the check is not clean (`bankGap` or `bookGap` ≠ 0), the run raises a **`continuity_break`**
830
+ exception. Its default severity is **`error`** (a broken roll-forward means the books do not actually
831
+ continue from the signed prior period), so it **FAILs the gate (exit `3`)** even if the period's own three
832
+ balances otherwise tie out. The exception **names the gap** (signed integer cents) and the **prior period**
833
+ it chained from, and it flows through the rendered packet: the HTML shows a **"Period continuity
834
+ (roll-forward)"** table (Prior ending → This opening → Gap) and, on a break, a **"Roll-forward break:"**
835
+ callout; the balances CSV carries `continuity,prior_period` / `continuity,bank_gap` rows and the exceptions
836
+ CSV carries the `continuity_break` row.
837
+
838
+ Like every other exception type, `continuity_break` is a **legal exception type** a per-state policy MAY
839
+ **re-grade** — e.g. a state that treats a documented timing roll-forward difference as a `warning` (with a
840
+ citation) rather than an out-of-trust ERROR. Re-grading it to `warning` removes it from the error count, so
841
+ the verdict no longer FAILs on the break alone. (See **The per-state policy layer** above; the bundled list
842
+ of legal types includes `continuity_break`.)
843
+
844
+ ### A close is an UNTRUSTED hint — re-derived, not signed
845
+
846
+ This is load-bearing and consistent with the project-wide trust posture
847
+ ([`docs/TRUST-BOUNDARIES.md`](TRUST-BOUNDARIES.md)): **the close artifact is an UNTRUSTED CONVENIENCE
848
+ HINT, not an authority.** It carries the prior period's **asserted** ending so the next run can seed and
849
+ check the opening — but the **authoritative** numbers are always the **freshly recomputed** reconciliation,
850
+ never the values written in the close. A broker who hand-edits the close file changes a *hint*, not the
851
+ truth: the next reconciliation **re-derives** the three balances from the source files, and the continuity
852
+ check merely reports whether the asserted roll-forward matched. The close is **NOT signed and NOT
853
+ timestamped**; like every other artifact in this repo it rides the human trust-root — the broker remains
854
+ the legal custodian and a CPA review still governs.
855
+
856
+ > **The close artifact is a convenience for chaining periods — NOT a legal record.** It exists to seed and
857
+ > check the next month's opening; it does not attest to anything, does not certify the prior period, and is
858
+ > not evidence of compliance. The audit-ready evidence is the dated **packet** (HTML + CSV) each run emits,
859
+ > read against the broker's actual books by a qualified CPA — exactly as for a single-period run. Emitting
860
+ > or chaining a close changes none of the honest-posture disclaimer at the top of this document.
861
+
862
+ ### Worked example: month 1 → month 2 → break
863
+
864
+ Run **month 1** with `--emit-close`. It reconciles and writes the close artifact:
865
+
866
+ ```
867
+ $ vh trust reconcile bank-2026-05.csv ledger-2026-05.csv rentroll-2026-05.csv \
868
+ --period 2026-05 --date 2026-05-31 --emit-close month1.json
869
+ PASS: three-way reconciliation tie out (...); 1 exception(s) [0 error, 0 warning, 1 info]
870
+ wrote close month1.json
871
+ ```
872
+
873
+ `month1.json` records the period's ending (say bank $3,300.00 / book $3,300.00) plus its `inputsDigest`.
874
+
875
+ Now run **month 2** with `--prior-close month1.json`. The opening is **seeded** from month 1's ending and
876
+ the roll-forward is checked. When month 2's data continues cleanly, **continuity holds** — no break, the
877
+ three balances tie out, and the gate PASSes:
878
+
879
+ ```
880
+ $ vh trust reconcile bank-2026-06.csv ledger-2026-06.csv rentroll-2026-06.csv \
881
+ --period 2026-06 --date 2026-06-30 --prior-close month1.json --emit-close month2.json; echo "exit=$?"
882
+ PASS: three-way reconciliation tie out (...); 1 exception(s) [0 error, 0 warning, 1 info]
883
+ wrote close month2.json
884
+ exit=0
885
+ ```
886
+
887
+ Now **break a balance**: re-run month 2 but force an opening that does **not** roll forward from the prior
888
+ close (here the bank opening is $100 below the prior ending — a skipped/edited/re-keyed period, the exact
889
+ footgun this guard exists for). The override is honored-and-noted, the continuity check flags the gap, and
890
+ the **`CONTINUITY_BREAK` FAILs** the gate (exit `3`):
891
+
892
+ ```
893
+ $ vh trust reconcile bank-2026-06.csv ledger-2026-06.csv rentroll-2026-06.csv \
894
+ --period 2026-06 --date 2026-06-30 --prior-close month1.json --opening-bank 3,200.00; echo "exit=$?"
895
+ note: --opening-bank 320000 overrides the prior close's ending bank balance 330000; the roll-forward continuity check below will flag the resulting gap
896
+ FAIL: ... ; N exception(s) [1 error, ...]
897
+ exit=3
898
+ ```
899
+
900
+ The packet names the prior period (`2026-05`), shows the roll-forward break (`bankGap = -10000`, i.e.
901
+ −$100.00), and the `continuity_break` row is ERROR — so the FAIL is *because the chain broke*, not because
902
+ the month's own numbers disagreed. That is the continuity layer doing its job: a silently-shifted opening
903
+ becomes a visible, gating finding.
904
+
905
+ ### Additivity (no close flags == today's behaviour)
906
+
907
+ With **neither** `--prior-close` nor `--emit-close`, the run is **byte-for-byte** the prior behaviour:
908
+ `model.continuity` and `model.priorClose` are `null`, no `continuity_break` is ever raised, nothing extra
909
+ is written, and the verdict depends only on the period's own three balances (and any selected policy). The
910
+ continuity layer only engages when you opt in by chaining a close.
911
+
912
+ ---
913
+
914
+ ## Sealing the packet: tamper-evident, independently verifiable
915
+
916
+ The audit packet a broker hands a state real-estate examiner months later is, by default, a **printout**:
917
+ nothing lets the examiner — or the broker defending themselves — prove "this is the **exact** packet
918
+ TrustLedger produced from these **exact** source files, byte-for-byte unaltered." A text editor can
919
+ silently rewrite a dollar figure and nothing detects it. The optional **seal** closes that evidentiary
920
+ gap. With `--seal`, `reconcile` (after writing the packet) emits a small JSON **seal** that binds the
921
+ **three source inputs**, **every emitted packet file**, **and** the run's **verdict** (PASS/FAIL,
922
+ `reportDate`, `period`) plus each input's **logical role** into **one content-addressed Merkle root**. The
923
+ read-only, offline `verify-seal` later **re-derives** that root from the bytes on disk and confirms — or
924
+ pinpoints exactly what changed.
925
+
926
+ The seal **reuses the project's proven provenance core verbatim** (`cli/core/manifest.js` /
927
+ `cli/hash.js` `hashEntries`/`pathLeaf`/`buildTree`, the same convention `vh hash <dir>` and the on-chain
928
+ `verifyLeaf` use). There is **no second hashing scheme**, no new dependency, no contract change, no
929
+ network, and no key. The seal module (`trustledger/seal.js`) is **pure / I-O-free / byte-deterministic**:
930
+ the CLI reads the files and hands it already-loaded `{ relPath, bytes }` entries; given the same inputs it
931
+ returns a byte-identical seal.
932
+
933
+ > **Read this too — what the seal IS, and is NOT.** A seal is **tamper-evidence**, **NOT a trusted
934
+ > timestamp** and **NOT a legal opinion**. It proves the inputs + packet are byte-for-byte what was
935
+ > sealed, and that the recorded verdict/date/period and each input's role are bound into the **same**
936
+ > root — but it does **NOT** prove **WHEN** the sealing happened. The `reportDate` is bound into the root
937
+ > so it cannot be edited undetected, yet a self-asserted date still rides the **human-owned trust-root**:
938
+ > standing up a real signing key or a trusted timestamp for "**sealed on date T**" is **P-3** (see
939
+ > [`docs/TRUST-BOUNDARIES.md`](TRUST-BOUNDARIES.md)) and is a **needs-human** step the loop never executes.
940
+ > The seal also does **NOT** validate whether the reconciliation is **correct** or **compliant** — the
941
+ > custodian/CPA posture at the top of this document is unchanged: TrustLedger **aids** reconciliation, the
942
+ > broker remains the responsible legal custodian, and a qualified CPA must still review the packet. The
943
+ > seal makes that review one of a **tamper-evident** packet, not an editable printout.
944
+
945
+ ### The seal schema
946
+
947
+ A seal is a single JSON object (`trustledger/seal.js` is the single source of truth: pure `buildSeal` /
948
+ `validateSeal` / `readSeal` / `serializeSeal` / `verifySeal`). **Every field is UNTRUSTED transport** —
949
+ `verify-seal` re-derives the root from the supplied bytes and never trusts the seal's own stored hashes.
950
+ Every field:
951
+
952
+ | Field | Type | What it is |
953
+ | --- | --- | --- |
954
+ | `kind` | string `"trustledger.reconcile-seal"` | Identity, disjoint from the dataset/parcel manifests so a seal can never be confused for one of them. Any other value is a hard, named `SealError`. |
955
+ | `schemaVersion` | integer (currently **1**) | Pins the seal shape. Any unsupported version is a hard `SealError` — never silently coerced. |
956
+ | `note` | string | The standing in-band trust caveat (tamper-evidence, NOT a timestamp, NOT a legal opinion; verify re-derives). `validateSeal` REJECTS a seal whose `note` has drifted, so the caveat can never be quietly stripped. |
957
+ | `root` | 0x + 64-hex | The single content-addressed Merkle **root** over the **whole committed set**: the inputs + the outputs + a synthetic verdict/role **HEADER** leaf. This is the load-bearing field — `verify-seal` recomputes it from the bytes on disk. |
958
+ | `fileCount` | non-negative integer | The number of real files committed (inputs + outputs). The header leaf is re-derived, not listed, so it is not counted. Must match the entry total or it is a `SealError`. |
959
+ | `verdict` | `{ pass: boolean, reportDate: "YYYY-MM-DD", period: string \| null }` | The recorded reconcile **facts** — what the seal NAMES that it sealed. These are bound into the HEADER leaf (and thus the root), so editing any of them makes the root fail to re-derive. They are FACTS the seal carries, **not** proofs (a bound date is still not a trusted timestamp). |
960
+ | `inputs` | array of `{ role, relPath, contentHash, leaf }` | The three source files, each tagged with its logical **role** — one of `bank`, `book`, `rentroll` — used **at most once** (no duplicate/unknown role). `contentHash` is the SHA-256 of the file bytes; `leaf` is the path-bound `pathLeaf(relPath, contentHash)`. Sealed by **basename** so the binding travels next to the packet. |
961
+ | `outputs` | array of `{ relPath, contentHash, leaf }` | Every emitted packet file (the HTML + CSV, plus any `--emit-close` close artifact). No `role` (roles partition INPUTS only). |
962
+
963
+ The synthetic **HEADER leaf** is *not* a stored field — it is re-derived deterministically on
964
+ validate/verify from the seal's own `verdict` + the input role→relPath bindings, hashed and path-bound by
965
+ the **same** `pathLeaf` convention every real file uses. That is why the verdict and the role partition
966
+ are tamper-EVIDENT in the **same** root as the files, with **no second hashing scheme**: editing
967
+ `verdict.pass`, the `reportDate`, the `period`, OR swapping an input's role changes the header content →
968
+ its leaf → the root, which then no longer re-derives. `validateSeal` is **strict** — a wrong
969
+ `kind`/`schemaVersion`, a drifted `note`, a missing/garbled verdict, a missing/duplicate/unknown input
970
+ role, a malformed hex `contentHash`/`leaf`/`root`, a `leaf` inconsistent with its `(relPath,
971
+ contentHash)`, or a `root` that does not re-derive from the listed entries + the verdict/role header is a
972
+ named `SealError`, never half-accepted.
973
+
974
+ ### The `--seal` write flow
975
+
976
+ ```
977
+ vh trust reconcile <bank> <ledger> <rentroll> --out <dir> --seal [<file>]
978
+ ```
979
+
980
+ - `--seal` **requires `--out`**: without `--out` the command writes **nothing** (it streams to stdout), so
981
+ there is no emitted packet to seal — passing `--seal` alone is a **usage error (exit `2`)**.
982
+ - The seal is emitted **AFTER** every packet file (and after any `--emit-close` close), so it binds the
983
+ **whole** emitted artifact set.
984
+ - Without a `<file>`, the seal lands at a default name **next to the packet**:
985
+ `reconciliation-<reportDate>-seal.json` inside `--out`. A caller-named `--seal <file>` writes there
986
+ instead.
987
+ - The three source **inputs** are sealed by their **basename** (e.g. `bank.csv`) so the portable handoff
988
+ ships each source next to the seal; the packet **outputs** are sealed by their seal-dir-relative path
989
+ (a basename when the seal sits in the `--out` dir, the common case). If two sealed files would flatten
990
+ to the **same name**, that is a named IO error (exit `1`) telling you to rename a source — the partition
991
+ must stay unambiguous.
992
+
993
+ ### The offline `verify-seal` flow
994
+
995
+ ```
996
+ vh trust verify-seal <sealfile> [--dir <d>] [--inputs <d>] [--json]
997
+ ```
998
+
999
+ This is the **independent** companion: given **only** the seal file (and the files it names), it
1000
+ re-derives each listed file's content hash and the manifest root **from the bytes on disk** and compares
1001
+ against the seal's stored expectation. It needs **no key, no network, no contract** — purely the seal
1002
+ core's `verifySeal`, and it **writes nothing**.
1003
+
1004
+ - The seal is **read and strictly validated first** (`readSeal`). A malformed or unreadable seal is an
1005
+ **IO error (exit `1`)** — it is never half-accepted nor treated as "everything changed".
1006
+ - **Output files** resolve relative to `--dir` (if given) else the **seal file's own directory** (the seal
1007
+ stored output relPaths relative to where it was written). **Source inputs** (sealed by basename) resolve
1008
+ relative to `--inputs` (if given) else the **same base dir** — the portable handoff ships the sources
1009
+ next to the seal, so the default just works; `--inputs <d>` is for an examiner who keeps the originals in
1010
+ a separate folder.
1011
+ - A sealed file that is **absent** on disk is **not** an abort — it is localized as **MISSING** (the verify
1012
+ tolerates a partial supplied set). The verdict is **ACCEPTED** only when **every** sealed file MATCHes,
1013
+ none is MISSING/UNEXPECTED, no role mismatched, AND the recomputed root equals the sealed root.
1014
+
1015
+ **Exit codes** (mirroring the rest of the family):
1016
+
1017
+ | Exit | Meaning |
1018
+ | --- | --- |
1019
+ | `0` | **ACCEPTED** — every sealed file re-derives byte-for-byte, no role swap, and the root matches |
1020
+ | `3` | **REJECTED** — at least one CHANGED / MISSING / UNEXPECTED file, a role mismatch, or the root does not re-derive (the report lists exactly which) |
1021
+ | `2` | usage error (missing `<sealfile>`, bad/unknown flag, extra positional) |
1022
+ | `1` | IO error (the seal file is unreadable or not a valid seal) |
1023
+
1024
+ ### Per-file CHANGED / MISSING / UNEXPECTED (the localization)
1025
+
1026
+ `verify-seal` is **authoritative by re-computing** from the supplied bytes, and it **localizes** every
1027
+ change so no tampered file can verify clean. Each file lands in exactly one bucket:
1028
+
1029
+ - **MATCH** — present in both, recomputed `contentHash` equals the sealed one.
1030
+ - **CHANGED** — present in both, recomputed `contentHash` **differs** (a tamper, localized to that exact
1031
+ file; the report prints the sealed vs on-disk hash).
1032
+ - **MISSING** — sealed, but absent from the supplied set (a dropped/renamed file).
1033
+ - **UNEXPECTED** — supplied, but **not** named in the seal (an added/renamed file).
1034
+ - **ROLE** — a file present in both whose **supplied role differs from its sealed role** (a bank↔book
1035
+ swap), surfaced and localized rather than silently accepted.
1036
+
1037
+ Because the verdict and the role bindings are committed into the **same** root, editing the verdict
1038
+ (PASS↔FAIL, the date, the period) or swapping a role makes the **recomputed root** differ — `rootMatches`
1039
+ goes `false` and the run REJECTs — even when every file's own bytes are untouched. The header change is
1040
+ reported against the seal HEADER (the root no longer re-derives), exactly as a file change is reported
1041
+ against its path.
1042
+
1043
+ ### The seal MAY be signed (the shared attestation envelope)
1044
+
1045
+ A seal is, by itself, **unsigned**. It MAY be **wrapped** by the project's existing signed-attestation
1046
+ envelope (`cli/core/attestation.js`) so a human can vouch for it via the **same** shared signing path —
1047
+ the seal's canonical bytes (`serializeSeal`) become the attestation payload; `signSealWith` /
1048
+ `verifySignedSeal` round-trip it. That signature proves **WHO** vouched for the sealed packet — still
1049
+ **not** a trusted timestamp ("sealed since date T" remains the human trust-root, **P-3**) and still not a
1050
+ legal opinion (the CPA review governs). Provisioning a real signing key is a **needs-human** step the loop
1051
+ never performs.
1052
+
1053
+ ### Worked example: reconcile `--seal` → hand over → `verify-seal`
1054
+
1055
+ Reconcile a month and seal the packet:
1056
+
1057
+ ```
1058
+ $ vh trust reconcile bank-2026-05.csv ledger-2026-05.csv rentroll-2026-05.csv \
1059
+ --period 2026-05 --date 2026-05-31 --out ./packets/may --seal
1060
+ PASS: three-way reconciliation tie out (...); 1 exception(s) [0 error, 0 warning, 1 info]
1061
+ wrote ./packets/may/reconciliation-2026-05-31-balances.csv
1062
+ wrote ./packets/may/reconciliation-2026-05-31-exceptions.csv
1063
+ wrote ./packets/may/reconciliation-2026-05-31.html
1064
+ wrote seal ./packets/may/reconciliation-2026-05-31-seal.json
1065
+ ```
1066
+
1067
+ The packet is **three** files — the HTML report plus the **balances** and **exceptions**
1068
+ CSVs — so the seal binds **6** files: the **3** source inputs (bank / book / rentroll) plus
1069
+ those **3** emitted outputs.
1070
+
1071
+ **Hand over** the `--out` directory **plus** the three source files (copied next to the seal) and the seal
1072
+ itself. Months later, an examiner verifies it offline — no key, no network:
1073
+
1074
+ ```
1075
+ $ vh trust verify-seal ./packets/may/reconciliation-2026-05-31-seal.json; echo "exit=$?"
1076
+ # vh trust verify-seal — ./packets/may/reconciliation-2026-05-31-seal.json
1077
+ The broker remains the responsible trust-account custodian. A seal is TAMPER-EVIDENT, NOT a trusted timestamp ... verify-seal RE-DERIVES the root from the files on disk ...
1078
+ sealed root: 0x...
1079
+ recomputed root: 0x...
1080
+ root matches: yes
1081
+ sealed verdict: PASS (reportDate 2026-05-31, period 2026-05)
1082
+ files: 6 matched, 0 changed, 0 missing, 0 unexpected, 0 role-mismatched
1083
+
1084
+ ACCEPTED — every sealed file re-derives byte-for-byte and the root matches.
1085
+ exit=0
1086
+ ```
1087
+
1088
+ Now **tamper** with one packet file — edit a dollar figure in the HTML — and re-verify. The change is
1089
+ **localized** and the run REJECTs:
1090
+
1091
+ ```
1092
+ $ vh trust verify-seal ./packets/may/reconciliation-2026-05-31-seal.json; echo "exit=$?"
1093
+ ...
1094
+ root matches: NO
1095
+ files: 5 matched, 1 changed, 0 missing, 0 unexpected, 0 role-mismatched
1096
+
1097
+ REJECTED — the files on disk do NOT match the seal:
1098
+ CHANGED reconciliation-2026-05-31.html: sealed 0x... != on-disk 0x...
1099
+ exit=3
1100
+ ```
1101
+
1102
+ The same REJECT-and-localize happens if you **drop** a sealed file (`MISSING`), **add** one (`UNEXPECTED`),
1103
+ **rename** one (a `MISSING` + an `UNEXPECTED`), **swap** the bank and book inputs (`ROLE`), or edit the
1104
+ **verdict/date/period** (the root no longer re-derives). No tampered file can verify clean.
1105
+
1106
+ ---
1107
+
1108
+ ## The packet: HTML + balances/exceptions CSV (print-to-PDF ready)
1109
+
1110
+ With `--out <dir>`, the command writes a **dated** packet into that directory (created if absent):
1111
+
1112
+ - **HTML** (`reconciliation-<date>.html`) — a single self-contained document. Open it in any browser
1113
+ and **Print → Save as PDF** to file the reconciliation with your records.
1114
+ - **balances CSV** (`reconciliation-<date>-balances.csv`) — the three-way balance lines as a
1115
+ spreadsheet, so the tie-out arithmetic is re-checkable column by column.
1116
+ - **exceptions CSV** (`reconciliation-<date>-exceptions.csv`) — the exception list as a spreadsheet,
1117
+ so a bookkeeper can work the findings line by line.
1118
+
1119
+ That is **three** files per run; `--seal` binds all three (plus the three source inputs) into one root.
1120
+
1121
+ Binary PDF/xlsx generation is **deferred to v2** on purpose: HTML prints to PDF and CSV opens in any
1122
+ spreadsheet, so the packet needs **zero new heavy dependencies** and carries zero install risk. The
1123
+ packet leads with the disclaimer above and is byte-reproducible for a given report date.
1124
+
1125
+ ### Filesystem hygiene
1126
+
1127
+ Side-effect files are written **only** to the caller-chosen `--out` directory — **never** silently to
1128
+ the current working directory. Without `--out`, the command prints the summary plus the HTML report to
1129
+ stdout and **writes nothing**, so it is safe to run anywhere and trivially pipeable in CI.
1130
+
1131
+ ---
1132
+
1133
+ ## The web front-door: `vh trust serve`
1134
+
1135
+ A property-management broker will never use a terminal. `vh trust serve` launches a small **local web
1136
+ front-door** over the exact same engine so a broker can open a browser, drag the three monthly files
1137
+ in, and watch the balances tie out — no command line required.
1138
+
1139
+ ```
1140
+ vh trust serve [--port <n>] [--host <h>]
1141
+ ```
1142
+
1143
+ | Option | Default | Meaning |
1144
+ | ------------- | ---------------- | ------------------------------------------------------------- |
1145
+ | `--port <n>` | `4173` | TCP port to listen on (`0` = let the OS pick a free port) |
1146
+ | `--host <h>` | `127.0.0.1` | interface to bind (localhost by default — see deploy posture) |
1147
+
1148
+ It prints the URL and then stays running until you stop it (Ctrl-C):
1149
+
1150
+ ```
1151
+ $ vh trust serve
1152
+ TrustLedger web door listening on http://127.0.0.1:4173/
1153
+ Files are processed IN MEMORY; nothing is written to disk server-side.
1154
+ This binds to localhost — to expose it, put it behind YOUR nginx/Cloudflare
1155
+ on YOUR own domain with TLS (a human deploy step; it is never auto-deployed).
1156
+ Press Ctrl-C to stop.
1157
+ ```
1158
+
1159
+ Open `http://127.0.0.1:4173/`, drop the **bank statement**, the **QuickBooks ledger**, and the **rent
1160
+ roll**, and the page shows the PASS/FAIL verdict, the three balances, the exception list, and the same
1161
+ audit-ready HTML packet the CLI produces — all rendered from the engine's response.
1162
+
1163
+ ### In-browser onboarding: inspect & map a file that won't load (no terminal)
1164
+
1165
+ A non-technical broker's **first** contact with the tool is "does my real export load?" — and a real
1166
+ bank/QuickBooks/rent-roll export routinely has a header no built-in alias matches. On the CLI that is
1167
+ the `vh trust inspect` / `--map` self-service fix (see **Onboarding: inspect before you reconcile**
1168
+ above). The web door exposes the **same** fix **in the browser**, so the buyer who will never open a
1169
+ terminal can do it too — the onboarding step is the **page**, not a command line.
1170
+
1171
+ 1. **Drop a file.** If it does not parse cleanly, the page does **not** dead-end on a raw error. It
1172
+ shows the file's **detected header columns**, a **logical-field → matched-column** table (the same
1173
+ `diagnoseSource` report the CLI prints), the **parse tally**, a **sample**, and **every** failing
1174
+ row — never a stack trace.
1175
+ 2. **Map a missing field.** For each **required** field the auto-detect could not bind, the page shows a
1176
+ **dropdown of the file's actual header columns**. Pick the column that holds that field and press
1177
+ **Confirm mapping**; the page re-checks the file under your map and clears the miss when it lines up
1178
+ (or shows what is still missing).
1179
+ 3. **Reconcile.** The map you confirmed is **threaded into the real reconcile run** for that file (under
1180
+ the same `bank`/`ledger`/`rentroll` key the engine uses), so the fix applies to the actual three-way
1181
+ reconciliation — not just the preview. Drop all three files and watch the balances tie out.
1182
+
1183
+ This is the **browser equivalent** of the CLI `vh trust inspect <file> --as <type> --map <logical>=<header>`
1184
+ loop: it runs the **same** `diagnoseSource` parse primitives (via the read-only `POST /api/inspect`
1185
+ endpoint, which writes nothing server-side, exactly like `/api/reconcile`), and it accepts the **same**
1186
+ `{ <logicalField>: <headerName> }` column-map override. A clean in-browser inspect means the file
1187
+ **loads**, **not** that the books are **right** — the three-way reconciliation, and a qualified CPA's
1188
+ review of the packet, still govern, exactly as the disclaimer at the top of this document states.
1189
+
1190
+ If the port cannot be bound (already in use, a privileged port without permission, or a bad `--host`
1191
+ interface), `serve` prints `error: cannot start TrustLedger web door: …` to stderr and **exits `1`**
1192
+ (the IO class) — it never exits `0` on a failed bind. That makes `vh trust serve || alert` safe to wire
1193
+ into a supervisor, systemd unit, or CI healthcheck: a non-zero exit means the door is genuinely down.
1194
+
1195
+ ### How a broker runs it locally
1196
+
1197
+ 1. Install the tool (`npm install -g .` from a checkout, or `npm link` — see the README install note).
1198
+ 2. Run `vh trust serve` (optionally `--port <n>` to choose a port).
1199
+ 3. Open the printed URL in a browser on the **same machine** and reconcile.
1200
+
1201
+ That is the whole local workflow: one command, one browser tab, no terminal interaction with the files
1202
+ themselves.
1203
+
1204
+ ### File privacy posture (this is the point of `serve`)
1205
+
1206
+ - The browser reads the three files **locally** (via the browser's `FileReader`) and POSTs their text
1207
+ contents to the server. The server runs the pipeline **purely in memory** and returns the verdict,
1208
+ balances, exception list, and rendered packet in the HTTP response.
1209
+ - **Nothing is persisted server-side.** `serve` has **no** `--out` flag — a long-lived server must never
1210
+ silently accumulate a broker's trust-account files on disk. (The path that *writes* a packet is the
1211
+ CLI `vh trust reconcile --out <dir>`, and only to the directory you name; see "Filesystem hygiene".)
1212
+ - A malformed file comes back as a **named JSON error** (HTTP 400) with the same located reason the CLI
1213
+ prints — never a stack trace. An oversized upload is rejected (HTTP 413) before it is fully buffered,
1214
+ so a hostile client cannot exhaust the process.
1215
+ - The returned packet carries the **same custodian disclaimer** the CLI packet does: the tool *aids*
1216
+ reconciliation; the broker remains the responsible trust-account custodian.
1217
+
1218
+ ### Deploying it (a HUMAN step — never auto-deployed)
1219
+
1220
+ By default `serve` binds **localhost** (`127.0.0.1`), so it is reachable only from the machine it runs
1221
+ on. To make it reachable to others, **you** put it behind a reverse proxy you control:
1222
+
1223
+ > **HUMAN deploy step.** Run `vh trust serve` on your own host and terminate TLS in front of it with
1224
+ > **nginx** or **Cloudflare** on **your own domain** (e.g. proxy `https://reconcile.yourbrokerage.com`
1225
+ > → `http://127.0.0.1:4173/`). Add whatever access control your trust-account data requires (basic
1226
+ > auth, an allow-list, SSO). The loop **never** deploys this for you, never exposes it to a public
1227
+ > network, and never binds anything but localhost by default. Hosting, TLS, the domain, and access
1228
+ > control are all yours to own.
1229
+
1230
+ Because the server persists nothing, a single instance is stateless and safe to restart at any time.
1231
+
1232
+ ---
1233
+
1234
+ ## Zero-install: the offline app (`trustledger-standalone.html`)
1235
+
1236
+ Even `vh trust serve` still asks the design partner to install Node + this repository and to trust a
1237
+ locally-running server with live trust-account data — the exact step where a pilot with a broker of
1238
+ record dies. The **zero-install pilot path** removes it entirely: the whole engine + the drag-drop UI
1239
+ above are emitted as **ONE self-contained HTML file**,
1240
+ [`trustledger/dist/trustledger-standalone.html`](../trustledger/dist/trustledger-standalone.html).
1241
+ **You email that ONE file** to the design partner (or hand it over on a USB stick); there is nothing
1242
+ else to send and nothing to install.
1243
+
1244
+ ### The flow (what the partner actually does)
1245
+
1246
+ 1. **Double-click** `trustledger-standalone.html`. It opens as an ordinary page in their browser —
1247
+ no install, no terminal, no server starts, no account.
1248
+ 2. **Drag their REAL exports** onto the page's three file inputs — the **bank statement**, the
1249
+ **QuickBooks trust ledger**, and the **rent roll** — the same drag-drop three-file flow as the web
1250
+ door, including the in-page **"Check this file"** inspect/map onboarding (a file that won't load
1251
+ shows its columns and a dropdown to map the missing field, exactly as described above).
1252
+ 3. **Read the same tie-out report** the CLI and web door produce: the PASS/FAIL verdict, the three
1253
+ balances, the exception list, and the downloadable audit-ready HTML + CSV packet.
1254
+
1255
+ ### The privacy claim (honest AND verifiable — not a promise)
1256
+
1257
+ The page makes **NO network request**. That is not a policy statement to take on trust; it is a
1258
+ property of the file itself: the emitted file **contains no network API at all** — no `fetch(`, no
1259
+ `XMLHttpRequest`, no `WebSocket`, no `EventSource`, no `sendBeacon`, no dynamic `import(` —
1260
+ so **check the browser devtools Network tab yourself** while you use it: it stays empty, and the
1261
+ trust-account data **never leaves the machine**. That token-level absence is pinned by
1262
+ [`test/trustledger.standalone.test.js`](../test/trustledger.standalone.test.js), which scans every
1263
+ emitted byte. A recipient can also confirm the file they received is the published one
1264
+ (`sha256sum -c trustledger-standalone.html.sha256`), and the bundle rebuilds **byte-identically**
1265
+ from the committed sources (`node trustledger/build-standalone.js --check` — a stale bundle fails CI).
1266
+
1267
+ ### Two INDEPENDENT monthly tie-outs on real data (the free, zero-install pilot surface)
1268
+
1269
+ The sharpened P-5 ask's riskiest step was *"have the design partner run their REAL month-1 +
1270
+ month-2 files"* — which used to require Node, this repository, and `vh trust serve`. With the offline
1271
+ app, that step becomes **"email them ONE file"**: **month 1** — drag the three real files, read the
1272
+ tie-out; **month 2** — drag that month's three files, read the tie-out. That is **two INDEPENDENT
1273
+ monthly tie-outs on real data** — a FREE, zero-install willingness-to-pay signal that the recurring
1274
+ monthly product ties out on the partner's *own* exports (see **What stays a human step** below).
1275
+
1276
+ **What the offline app does NOT do — the machine-checked continuity roll-forward stays CLI-only.**
1277
+ The offline app runs each month as an **independent single-month reconcile**: its UI has only the
1278
+ **three file pickers** (bank / ledger / rent roll) plus the state and license fields — there is **no
1279
+ prior-close input and no close-download**, faithful to the web door, which is also continuity-less.
1280
+ So it does **not** perform the *roll-forward* of P-5 step 3(b): carrying month-1's signed close into
1281
+ month 2 and machine-checking that the opening rolls forward penny-exact with **no `CONTINUITY_BREAK`**.
1282
+ That flag-driven close chain (`--emit-close` / `--prior-close`, see **Period-close continuity** above)
1283
+ lives **only in the installed product's CLI** — it is a distinct, installed capability, NOT part of
1284
+ the free zero-install surface.
1285
+
1286
+ The engine inside the file is the **same door core** the web door runs (inlined **verbatim**, not
1287
+ re-implemented), and it is pinned **byte-identical** to the installed engine
1288
+ ([`test/trustledger.standalone.test.js`](../test/trustledger.standalone.test.js)) — *including* on the
1289
+ exact two-month **`--prior-close` clean roll-forward** recipe the CLI close tests pin, driven through
1290
+ the bundle's engine at the **payload level**. That pin proves the bundle's *engine* faithfully
1291
+ supports the roll-forward (byte-for-byte with the installed one); it does **not** mean the offline
1292
+ app's UI delivers it — the UI exposes no way to feed a prior close, so a partner using the app gets
1293
+ the two independent monthly tie-outs above, and the continuity check remains a CLI step.
1294
+
1295
+ ### The honesty boundary (free tier ONLY — nothing human-owned moved)
1296
+
1297
+ The offline app is the FREE funnel tier — per-state policy tables, sealing, and licensing/fulfillment
1298
+ run in the installed product, and P-5's CPA/counsel review, vendor-key provisioning, pricing, and
1299
+ publishing steps remain HUMAN-OWNED and UNCHANGED (no new needs-human item, no relaxed gate).
1300
+ Concretely: requesting a paid surface in the offline app (a per-state policy or a seal) yields the
1301
+ **same named `license_required` refusal** the web door gives, and a pasted license is refused
1302
+ **fail-closed** (`license_invalid`, pointing at the installed product) — the offline bundle cannot
1303
+ verify a license at all, so it can never grant a paid surface. The gate is **reused verbatim, never
1304
+ weakened**.
1305
+
1306
+ ---
1307
+
1308
+ ## Entitlements & licensing
1309
+
1310
+ TrustLedger is **free to try** and **licensed for the paid surface**. The baseline three-way reconcile
1311
+ and the file inspector are open to anyone — a broker can prove the tool ties out their own files before
1312
+ paying a cent. The **paid features** (per-state policy packs, the tamper-evident seal) are gated behind a
1313
+ **signed, offline-verifiable license** the vendor issues to each paying customer.
1314
+
1315
+ A license is just one more product on the project's shared signed-attestation envelope (the same one the
1316
+ [seal](#sealing-the-packet-tamper-evident-independently-verifiable) uses): an **unsigned payload**, signed
1317
+ with the vendor's offline key, and verified locally by **re-deriving** the signer and pinning it to a
1318
+ **vendor address** the customer already trusts. There is **no license server**, **no network call**, and
1319
+ **no key on the customer's machine** — verification is pure and offline.
1320
+
1321
+ ### The free-vs-paid surface
1322
+
1323
+ | Surface | Tier | Entitlement required |
1324
+ | --- | --- | --- |
1325
+ | `vh trust reconcile` (baseline severities) | **Free** | — |
1326
+ | `vh trust inspect` / web "Check this file" | **Free** | — |
1327
+ | Web baseline reconcile (`POST /api/reconcile`, no `state`/`policy`/`seal`) | **Free** | — |
1328
+ | `--state` / `--policy` per-state policy packs (CLI **and** web) | **Paid** | `multi_state_policy` |
1329
+ | `--seal` tamper-evident reconciliation seal | **Paid** | `seal` |
1330
+ | Unlimited reconcile runs (no per-period cap) | **Paid** | `unlimited_reconcile` |
1331
+
1332
+ With **no** license the free paths behave **byte-for-byte** as they always did; only the paid surfaces are
1333
+ gated. A wrong, expired, or under-entitled license is a **named refusal** — it never silently downgrades to
1334
+ a free run.
1335
+
1336
+ ### The license payload schema
1337
+
1338
+ `vh trust license issue` mints a signed `*.vhlicense.json` whose **canonical payload** carries exactly these
1339
+ fields (every field is part of the signed bytes — editing any of them breaks the signature):
1340
+
1341
+ | Field | Type | Trusted vs hint |
1342
+ | --- | --- | --- |
1343
+ | `kind` | `"trustledger-license"` | **Structural** — fixes the artifact type; a wrong `kind` is rejected. |
1344
+ | `schemaVersion` | integer (`1`) | **Structural** — an unsupported version is rejected, never guessed. |
1345
+ | `note` | string | **Structural** — the standing trust caveat; `validateLicense` rejects a license whose note has drifted, so the caveat can never be quietly stripped. |
1346
+ | `licenseId` | non-empty string | **Hint** — the vendor's own identifier for this license (for the vendor's records; not interpreted by the gate). |
1347
+ | `customer` | non-empty string | **Hint** — who the license was issued to (self-asserted by the vendor; shown, not enforced). |
1348
+ | `plan` | non-empty string | **Hint** — the plan label the vendor sold (informational). |
1349
+ | `entitlements` | non-empty array of known flags | **Trusted** — the closed set of paid features this license unlocks. Drawn ONLY from the `ENTITLEMENTS` table below; an unknown flag is a hard error. This is what the gate consults. |
1350
+ | `issuedAt` | canonical ISO-8601 UTC instant | **Trusted-but-self-asserted** — the window start. The gate compares `now` against it, but it is the vendor's own clock (a self-asserted date, NOT a trusted timestamp — see TRUST-BOUNDARIES). |
1351
+ | `expiresAt` | canonical ISO-8601 UTC instant (strictly after `issuedAt`) | **Trusted-but-self-asserted** — the window end; same caveat. |
1352
+
1353
+ The **closed entitlement table** (the only place a paid feature enters the system) is:
1354
+
1355
+ | Entitlement flag | Unlocks |
1356
+ | --- | --- |
1357
+ | `multi_state_policy` | Multi-state trust-accounting policy packs (`--state` / `--policy`). |
1358
+ | `seal` | The tamper-evident reconciliation seal (`--seal` / `verify-seal`). |
1359
+ | `unlimited_reconcile` | Unlimited reconciliation runs (no per-period cap). |
1360
+
1361
+ A typo'd or forged entitlement can never grant a feature: it is not in the table, so `validateLicense`
1362
+ rejects it. To add a paid feature, a flag is added to the `ENTITLEMENTS` table — there is no other channel.
1363
+
1364
+ ### The license is an UNTRUSTED container — verification re-derives
1365
+
1366
+ Exactly like the close artifact and the seal (and per
1367
+ [`docs/TRUST-BOUNDARIES.md`](TRUST-BOUNDARIES.md)): **the license is an UNTRUSTED transport container.**
1368
+ `verifyLicense` never trusts the file's own claims. It **re-derives** the signer from the exact embedded
1369
+ bytes (EIP-191 recovery) and **pins** that recovered address to the `vendorAddress` the customer supplies.
1370
+ A license that merely *says* it was signed by the vendor but recovers to a different key is `wrong_issuer`,
1371
+ not trusted. Only when the signature re-derives to the pinned vendor key **and** `now` is within
1372
+ `[issuedAt, expiresAt]` is the verdict `valid`; only then do its entitlements mean anything.
1373
+
1374
+ The verify is **pure and offline**, taking `now` as an explicit argument (it never reads the system clock),
1375
+ so the same container + same `now` + same `vendorAddress` always yields a byte-identical verdict. The
1376
+ localized reject reasons are `malformed` / `bad_signature` / `wrong_issuer` / `not_yet_valid` / `expired`.
1377
+
1378
+ ### How a customer's tool verifies a license OFFLINE
1379
+
1380
+ Both the CLI and the web door run the **same** gate. The customer needs only (1) the signed
1381
+ `*.vhlicense.json` the vendor delivered and (2) the **vendor address** the vendor published — no key, no
1382
+ network:
1383
+
1384
+ ```
1385
+ vh trust license verify customer.vhlicense.json --vendor 0xVENDOR…
1386
+ # VALID -> exit 0 ; INVALID (wrong_issuer / expired / …) -> exit 3
1387
+ ```
1388
+
1389
+ On the **web door**, `POST /api/reconcile` accepts an optional `{ license, vendorAddress }` in the JSON body
1390
+ and threads the identical gate. A gated request (`state` / `policy` / `seal`) **without** a valid license is
1391
+ a **named 4xx**: `license_required` (402) when no license was supplied, or `license_invalid` (403) with the
1392
+ precise reason when one was. The page shows a clear *"this feature requires a license"* notice rather than a
1393
+ raw error. The server holds **no key** and verifies **offline** against the supplied `vendorAddress`.
1394
+
1395
+ ### Worked example: issue → verify → reconcile `--license`
1396
+
1397
+ The **vendor** (offline, with their own key) mints a license for a paying customer:
1398
+
1399
+ ```
1400
+ $ vh trust license issue \
1401
+ --customer "Acme Realty LLC" --plan pro-annual \
1402
+ --entitlements multi_state_policy,seal \
1403
+ --expires 2027-01-01T00:00:00.000Z \
1404
+ --key-env VENDOR_KEY --out acme.vhlicense.json
1405
+ # prints ONLY the PUBLIC vendor address + a summary + the path — the key is never echoed
1406
+ vendor: 0xVENDOR…
1407
+ wrote acme.vhlicense.json (customer "Acme Realty LLC", plan pro-annual, entitlements [multi_state_policy, seal])
1408
+ ```
1409
+
1410
+ The **customer** verifies it offline against the published vendor address, then runs the paid surface:
1411
+
1412
+ ```
1413
+ $ vh trust license verify acme.vhlicense.json --vendor 0xVENDOR…
1414
+ VALID — signed by the vendor, in-window; entitlements [multi_state_policy, seal]
1415
+
1416
+ $ vh trust reconcile bank.csv quickbooks.csv rentroll.csv \
1417
+ --state ca-example --out ./packets/may --seal \
1418
+ --license acme.vhlicense.json --vendor 0xVENDOR…
1419
+ # the per-state policy AND the seal are unlocked; the packet names the governing policy
1420
+ ```
1421
+
1422
+ Without `--license`/`--vendor`, the same `--state`/`--seal` run is refused with an actionable message and the
1423
+ free baseline reconcile remains available. The web door behaves identically: paste the license + vendor
1424
+ address into the License fieldset, select the state, and reconcile.
1425
+
1426
+ ---
1427
+
1428
+ ## Plan catalog & fulfillment
1429
+
1430
+ Issuing a license **by hand** for every sale does not scale: a human at a terminal had to remember the
1431
+ **exact** entitlement flags a tier grants and **hand-compute** the expiry (`--entitlements multi_state_policy,seal
1432
+ --expires 2027-01-01T00:00:00.000Z`). That is error-prone — a typo grants the wrong tier, a mis-keyed expiry
1433
+ drifts — and **un-automatable**: a billing provider's *payment-succeeded* event carries a **`planId`** and a
1434
+ **paid-through date**, not a comma-list of entitlement flags. The **plan catalog** + **`vh trust license
1435
+ fulfill`** close that gap: they turn "issue the right license" into **one deterministic command** a webhook can
1436
+ call, with **no hand-authored entitlement list**.
1437
+
1438
+ > **Boundary (VERBATIM — read this first).** The loop ships **ONLY** the catalog **schema** + the order→license
1439
+ > **mapping** + **ephemeral test keys**. It **NEVER** sets a price, holds a real key, runs a payment processor,
1440
+ > or takes a real payment. **Provisioning the vendor key, setting the PRICE/term column in the catalog, and
1441
+ > wiring the actual webhook/billing remain HUMAN-owned outward steps** (STRATEGY.md › P-6 step (3)). A plan is an
1442
+ > **access description** for delivered software value — which paid features a subscription unlocks and for how
1443
+ > long — **NOT a token, NOT tradeable, NOT an appreciating asset**, and the catalog makes **no claim of
1444
+ > regulatory compliance**.
1445
+
1446
+ ### The catalog schema
1447
+
1448
+ A plan catalog is a single, **versioned, strictly-validated** JSON file (`trustledger/plans.js` is the source of
1449
+ truth: pure `validatePlanCatalog` / `getPlan`, no I/O). It is the **one** machine-readable mapping `planId →
1450
+ { entitlements, term, displayName }` over the **CLOSED** `ENTITLEMENTS` table — so an unknown entitlement or a
1451
+ duplicate plan is a **hard build error**, never a silent mis-grant. Every field:
1452
+
1453
+ | Field | Required | Type | Meaning |
1454
+ | --- | --- | --- | --- |
1455
+ | `kind` | **yes** | string `"trustledger-plan-catalog"` | Fixes the artifact type, disjoint from a license/seal. A wrong/missing `kind` is a hard `PlanCatalogError`. |
1456
+ | `schemaVersion` | **yes** | integer (currently **1**) | Pins the catalog shape. Any unsupported version is a hard error — never coerced. |
1457
+ | `plans` | **yes** | non-empty array | The plan list. Emitted in `planId`-sorted order, deterministically. |
1458
+ | `plans[].planId` | **yes** | non-empty string | The plan id a billing `planId` resolves against. **Duplicate ids are rejected.** |
1459
+ | `plans[].displayName` | **yes** | non-empty string | A human label for the tier (shown, not enforced). |
1460
+ | `plans[].entitlements` | **yes** | non-empty array of **known** flags | The paid features this plan unlocks — drawn **ONLY** from the closed `ENTITLEMENTS` table (`multi_state_policy`, `seal`, `unlimited_reconcile`). An unknown or duplicate flag is a hard error. This is what `fulfill` copies into the license **verbatim**. |
1461
+ | `plans[].termDays` | **yes** | **positive integer** | The subscription term in days. When an order omits an explicit `--paid-through`, `expiresAt = issuedAt + termDays` days. A non-integer or non-positive term is rejected (never rounded/coerced). |
1462
+
1463
+ > **The PRICE/term column is the HUMAN fill-in.** The bundled catalog is a **DRAFT skeleton**: it ships the
1464
+ > `planId → entitlements/term/displayName` mapping, but **the price and your real term are YOURS to set**.
1465
+ > Editing the catalog (a data file in this validated schema) is exactly the narrow human step P-6 names — no
1466
+ > engine change is needed. The shipped `_DISCLAIMER` string is ignored by the engine and exists only to keep the
1467
+ > access-description posture attached to the file itself.
1468
+
1469
+ ### The bundled draft skeleton
1470
+
1471
+ The catalog `fulfill` resolves against when you pass **no** `--catalog` is the bundled draft
1472
+ (`trustledger/fixtures/plans/baseline.json`), read from **this package's own** fixtures dir — never the caller's
1473
+ cwd. Its draft plans:
1474
+
1475
+ | `planId` | `displayName` | entitlements | `termDays` |
1476
+ | --- | --- | --- | --- |
1477
+ | `solo-monthly` | Solo (monthly) | `seal` | `30` |
1478
+ | `pro-annual` | Pro (annual) | `seal`, `multi_state_policy` | `365` |
1479
+ | `firm-annual` | Firm (annual) | `seal`, `multi_state_policy`, `unlimited_reconcile` | `365` |
1480
+
1481
+ These are a **skeleton to copy**: keep/rename the plans, set **your** `termDays`, and attach **your** price
1482
+ out-of-band. Point `--catalog <file>` at your own catalog to override the bundle entirely.
1483
+
1484
+ ### `vh trust license fulfill` (the one-command shape)
1485
+
1486
+ ```
1487
+ vh trust license fulfill --plan <planId> --customer <name> [--paid-through <ISO>] [--catalog <file>]
1488
+ (--key-env <VAR> | --key-file <path>)
1489
+ [--issued <ISO>] [--license-id <id>] [--out <file>] [--json]
1490
+ ```
1491
+
1492
+ `fulfill` looks the `planId` up in the catalog, copies that plan's **entitlements VERBATIM** (never re-typed),
1493
+ derives the window (`--paid-through`, else `issuedAt + termDays`), and emits the **SAME** signed
1494
+ `*.vhlicense.json` the existing `vh trust license verify` / `reconcile --license` gate already accept
1495
+ byte-for-byte. The order→license mapping (`license.fulfillOrder`) is **pure + deterministic**: the same
1496
+ `{ plan, customer, paidThrough, issuedAt }` + the same catalog yields **byte-identical** license fields.
1497
+
1498
+ - The vendor key is read **EXACTLY ONE** of `--key-env <VAR>` / `--key-file <path>` and is
1499
+ **read-used-discarded** — the **same** posture as `license issue` / `vh dataset sign`. The loop **never holds**
1500
+ a key; **only the PUBLIC vendor address is echoed**, never the key. Neither/both/missing/malformed key sources
1501
+ hard-error (exit `2`) with a **key-free** message.
1502
+ - An **unknown plan**, a `--paid-through` **at or before** `issuedAt`, or a **malformed** `--issued`/`--paid-through`
1503
+ is a **usage error (exit `2`)** — a named reject, never a silent mis-grant.
1504
+ - With `--out <file>` the signed container is written to **that** path (and **only** there — never cwd); without
1505
+ `--out` it streams to stdout. `--json` round-trips the public summary (`vendor`, `entitlements`, `issuedAt`,
1506
+ `expiresAt`, …) so a webhook handler can script it.
1507
+
1508
+ ### From a billing event to a license: the webhook adapter
1509
+
1510
+ The catch the hand-wave above buries: a billing provider's webhook does **NOT** fire with **OUR** vocabulary.
1511
+ A real Stripe `invoice.paid` / `checkout.session.completed` (or a Paddle) event carries the **provider's own
1512
+ price/product id** (e.g. `price_...`) — **NOT** our `planId` — a `customer` reference, and a **period-end as a
1513
+ UNIX epoch in SECONDS** (`current_period_end`) — **NOT** the canonical ISO `fulfillOrder` strictly requires. And
1514
+ it is delivered **at-least-once**, so the *same* event can arrive twice. Two pure seams close that gap so the
1515
+ event→license path is a **real, deterministic pipeline**, not glue.
1516
+
1517
+ **(1) The `price→plan` binding (`trustledger/plans.js`).** A versioned, strictly-validated JSON file — the
1518
+ **one** machine-readable routing table mapping each `(provider, priceId)` onto one of **THIS** catalog's
1519
+ `planId`s. `validatePriceBinding(obj, catalog)` checks it **against the catalog** (every `planId` it points at
1520
+ must exist), so a price can **never** point at a non-existent plan, and an **unmapped** `(provider, priceId)` is a
1521
+ **named reject** — never a silent mis-grant of the wrong *plan* (the same class the catalog closed for
1522
+ entitlements, one level up). Every field:
1523
+
1524
+ | Field | Required | Type | Meaning |
1525
+ | --- | --- | --- | --- |
1526
+ | `kind` | **yes** | string `"trustledger-price-binding"` | Fixes the artifact type, disjoint from a license/seal/catalog. A wrong/missing `kind` is a hard `PriceBindingError`. |
1527
+ | `schemaVersion` | **yes** | integer (currently **1**) | Pins the binding shape. Any unsupported version is a hard error — never coerced. |
1528
+ | `mappings` | **yes** | non-empty array | The routing rows. Emitted in `(provider, priceId)`-sorted order, deterministically. |
1529
+ | `mappings[].provider` | **yes** | non-empty string (no NUL) | The billing provider id the event came from (e.g. `"stripe"`, `"paddle"`). |
1530
+ | `mappings[].priceId` | **yes** | non-empty string (no NUL) | The **provider's own** price/product id the event carries (e.g. a Stripe `price_...`). A **duplicate** `(provider, priceId)` is rejected. |
1531
+ | `mappings[].planId` | **yes** | non-empty string | One of **this catalog's** `planId`s. A `planId` **absent** from the supplied catalog is a hard error — so the binding can never route a paid event at a plan that does not exist. |
1532
+
1533
+ A bundled draft binding (`trustledger/fixtures/plans/price-binding.example.json`) shows the shape:
1534
+ `(stripe, price_pro_annual_usd) → pro-annual`, etc. Like the catalog, the **price-ids are YOURS to fill in** —
1535
+ the loop ships the **schema + the mapping**, not your real price-ids. Its `_DISCLAIMER` field is ignored by the
1536
+ engine.
1537
+
1538
+ **(2) The two-line pipeline: `normalizeEvent(rawEvent, binding) → fulfillOrder(order, catalog)`.** `normalizeEvent`
1539
+ is the **pure seam** that maps a normalized provider event envelope `{ provider, priceId, customer, periodEnd,
1540
+ issuedAt? }` onto the **exact** `{ plan, customer, paidThrough, issuedAt }` order `fulfillOrder` already consumes:
1541
+ it resolves `priceId → planId` via the binding (`plans.resolvePlanId`), converts the period-end **epoch seconds →
1542
+ canonical ISO `paidThrough`** (a non-integer / negative / out-of-range epoch is a named reject, never coerced),
1543
+ carries the `customer` (a missing/blank one is a named reject — a license with no holder is never minted), and
1544
+ takes `issuedAt` **only** from the caller (no hidden clock read, so the module stays pure/testable). So the whole
1545
+ event→license path is two composed, deterministic calls:
1546
+
1547
+ ```js
1548
+ // your webhook handler, AFTER it has authenticated the provider's signature (see below):
1549
+ const order = normalizeEvent(rawEvent, binding); // provider event -> { plan, customer, paidThrough, issuedAt }
1550
+ const license = fulfillOrder(order, catalog); // order -> the SAME signed-license params the gate accepts
1551
+ ```
1552
+
1553
+ Both calls are **pure + deterministic**: the same `rawEvent` + binding + catalog yields a **byte-identical**
1554
+ license every time, so `fulfillOrder(normalizeEvent(ev, binding), catalog)` is reproducible end-to-end (the
1555
+ `vh trust license fulfill` command is exactly this pipeline plus reading the vendor key and signing).
1556
+
1557
+ **(3) The idempotency rule: `orderKey(order)`.** Providers **retry** (Stripe documents at-least-once delivery), so
1558
+ the *same* event can arrive twice. `orderKey(order)` returns the **deterministic** seed **`LIC-<issuedAt>-<plan>`**
1559
+ — the **same** value `fulfillOrder` defaults the `licenseId` to. The rule: an idempotent handler **dedupes on
1560
+ `orderKey(order)`** — if a license already exists under that key, a retried delivery resolves to the **same** order
1561
+ → the **same** key → the handler returns the **already-minted, byte-identical** license rather than minting a
1562
+ second/different one. Because the key derives only from the order's own fields, a retried event is a no-op, not a
1563
+ double-grant or a double-delivery.
1564
+
1565
+ > **The ONE remaining HUMAN step: verify the provider's webhook SECRET.** `normalizeEvent` maps an
1566
+ > **already-authenticated** event — it does not call a provider API and it does not trust an unauthenticated
1567
+ > payload on its own. **Verifying the inbound webhook's signature against the provider's signing SECRET** (e.g.
1568
+ > `stripe.webhooks.constructEvent(body, sig, endpointSecret)`) is the integrator's job, done with the provider's
1569
+ > own SDK **BEFORE** `normalizeEvent` runs — and it needs the **provider's real signing secret**, which the loop
1570
+ > **never holds**. The loop ships the **binding + the normalizer + the idempotency key + ephemeral test keys**;
1571
+ > **verifying the provider's webhook secret, provisioning the vendor key, setting the price/term column in the
1572
+ > catalog, and wiring the actual webhook/billing remain HUMAN-owned outward steps** (STRATEGY.md › P-6 step (3)).
1573
+
1574
+ ### The worked flow: `payment-succeeded` webhook → `fulfill` → deliver `*.vhlicense.json`
1575
+
1576
+ A billing provider's *payment-succeeded / renewed* webhook fires with the **provider's** `priceId` and a
1577
+ period-end **epoch**. The handler authenticates it (the webhook-secret human step above), then runs the two-line
1578
+ `normalizeEvent → fulfillOrder` pipeline — equivalently, **one** `vh trust license fulfill` call — and delivers
1579
+ the file:
1580
+
1581
+ ```
1582
+ # (your webhook handler, on an AUTHENTICATED Stripe/Paddle "payment_succeeded" event)
1583
+ # const order = normalizeEvent(rawEvent, binding); // { provider, priceId, customer, periodEnd } -> { plan, customer, paidThrough, issuedAt }
1584
+ # const lic = fulfillOrder(order, catalog); // -> the same signed-license params the gate accepts
1585
+ # // dedupe on orderKey(order) === `LIC-<issuedAt>-<plan>`: a RETRIED event re-mints the BYTE-IDENTICAL license
1586
+
1587
+ $ vh trust license fulfill \
1588
+ --plan pro-annual --customer "Acme Realty LLC" \
1589
+ --paid-through 2027-01-01T00:00:00.000Z \
1590
+ --key-env VENDOR_KEY --out acme.vhlicense.json
1591
+ fulfilled TrustLedger license for plan pro-annual by vendor 0xVENDOR…
1592
+ entitlements: seal, multi_state_policy
1593
+ expiresAt: 2027-01-01T00:00:00.000Z
1594
+ written: /…/acme.vhlicense.json
1595
+ ```
1596
+
1597
+ Then **deliver** `acme.vhlicense.json` to the paying customer (email/download). They verify it offline against
1598
+ your published vendor address and run the paid surface — exactly the
1599
+ [issue → verify → reconcile `--license`](#worked-example-issue--verify--reconcile---license) flow above, but the
1600
+ license is now **minted with no terminal step per sale**:
1601
+
1602
+ ```
1603
+ $ vh trust license verify acme.vhlicense.json --vendor 0xVENDOR…
1604
+ VALID — signed by the vendor, in-window; entitlements [seal, multi_state_policy]
1605
+ ```
1606
+
1607
+ So the per-sale human work collapses to **EXACTLY**: (1) fill in **YOUR** price/term per `planId` in the catalog
1608
+ (the value column) and **YOUR** real price-ids in the `price→plan` binding, and (2) **verify the provider's
1609
+ webhook secret** with its SDK, then point your billing provider's *payment-succeeded / renewed* webhook at the
1610
+ two-line `normalizeEvent → fulfillOrder` pipeline (or the equivalent `vh trust license fulfill` command).
1611
+ The loop ships the catalog + the mapping (the `price→plan` binding + the `normalizeEvent` normalizer + the
1612
+ `orderKey` idempotency key); **verifying the provider's webhook secret**, the **price/term column**, the
1613
+ **vendor key**, and the **actual webhook/billing** stay **HUMAN** steps. This adds **no** new human gate — it
1614
+ **sharpens** P-6 step (3).
1615
+
1616
+ ---
1617
+
1618
+ ## Usage
1619
+
1620
+ ```
1621
+ vh trust reconcile <bank> <ledger> <rentroll> [options]
1622
+
1623
+ Positional (in order):
1624
+ <bank> bank statement (CSV or OFX)
1625
+ <ledger> QuickBooks ledger export (CSV) — the "book"
1626
+ <rentroll> rent roll (CSV) — the per-tenant sub-ledger
1627
+
1628
+ Options:
1629
+ --out <dir> write the HTML + CSV packet into <dir> (created if absent);
1630
+ without --out, print the summary + HTML to stdout, write nothing
1631
+ --seal [<file>] after the packet (and any --emit-close) is written, emit a
1632
+ TAMPER-EVIDENT reconciliation seal binding the 3 source inputs +
1633
+ every packet file + the verdict/role header into ONE Merkle root;
1634
+ REQUIRES --out. Default name: reconciliation-<date>-seal.json under
1635
+ <dir>. Verify later, offline, with `vh trust verify-seal <sealfile>`
1636
+ (see "Sealing the packet" above)
1637
+ --json emit the full model + exit-code contract as JSON
1638
+ --date <YYYY-MM-DD> pin the report date (default: today, UTC) — keeps output reproducible
1639
+ --period <label> optional human label for the statement period
1640
+ --state <code> score under a bundled per-state DRAFT policy by its code/label
1641
+ (trustledger/fixtures/policy/<code>.json); mutually exclusive with --policy
1642
+ --policy <file> score under an explicit per-state policy file you supply
1643
+ --prior-close <file> roll forward FROM a prior period's close artifact: seed this
1644
+ run's opening from it and check the roll-forward (see
1645
+ "Period-close continuity" below)
1646
+ --emit-close <file> write THIS run's close artifact to <file> so next month can
1647
+ consume it as --prior-close
1648
+ --opening-bank <amt> opening bank balance (e.g. "12,345.67"); default 0
1649
+ --opening-book <amt> opening book balance; default 0
1650
+ --tolerance-cents <n> tie-out tolerance in integer cents; default 0
1651
+ --bank-format csv|ofx force the bank-file format instead of auto-detecting
1652
+ --map <src>:<lf>=<hdr> bind a logical field to an EXACT column header when the
1653
+ alias auto-detect misses it; <src> is bank|ledger|rentroll
1654
+ (repeatable). See "Onboarding: inspect before you reconcile"
1655
+ --map-file <json> a { bank|ledger|rentroll: { <logical>: <header> } } file of
1656
+ the same per-source overrides (an inline --map wins on a clash)
1657
+
1658
+ vh trust verify-seal <sealfile> [--dir <d>] [--inputs <d>] [--json] # offline, read-only
1659
+ <sealfile> the seal emitted by `reconcile --seal`
1660
+ --dir <d> resolve OUTPUT files from <d> (default: the seal file's own dir)
1661
+ --inputs <d> resolve the SOURCE inputs from <d> (default: same as --dir / seal dir)
1662
+ --json emit the full per-file verifySeal result as JSON
1663
+ # exit: 0 ACCEPTED, 3 REJECTED (per-file CHANGED/MISSING/UNEXPECTED/role), 2 usage, 1 IO
1664
+
1665
+ vh trust serve [--port <n>] [--host <h>] # the local web front-door (see above)
1666
+ --port <n> listen port (default 4173; 0 = OS-chosen free port)
1667
+ --host <h> bind interface (default 127.0.0.1 / localhost)
1668
+ ```
1669
+
1670
+ ### Example
1671
+
1672
+ ```
1673
+ $ vh trust reconcile bank-2026-05.csv ledger-2026-05.csv rentroll-2026-05.csv --date 2026-05-31 --out ./packets/may
1674
+ PASS: three-way reconciliation tie out (bank-adjusted $128,400.00, book $128,400.00, sub-ledger $128,400.00); 1 exception(s) [0 error, 0 warning, 1 info]
1675
+ wrote ./packets/may/reconciliation-2026-05-31-balances.csv
1676
+ wrote ./packets/may/reconciliation-2026-05-31-exceptions.csv
1677
+ wrote ./packets/may/reconciliation-2026-05-31.html
1678
+ ```
1679
+
1680
+ A FAIL still writes the packet (so you can review every exception) and exits `3`:
1681
+
1682
+ ```
1683
+ $ vh trust reconcile bank.csv ledger.csv short-rentroll.csv --out ./packets/may; echo "exit=$?"
1684
+ FAIL: three-way reconciliation DO NOT tie out (bank-adjusted $128,400.00, book $128,400.00, sub-ledger $127,900.00); 2 exception(s) [1 error, 0 warning, 1 info]
1685
+ ...
1686
+ exit=3
1687
+ ```
1688
+
1689
+ ---
1690
+
1691
+ ## Onboarding: inspect before you reconcile
1692
+
1693
+ `reconcile` is **strict on purpose** — it parses each of your three files **fail-closed**: a missing
1694
+ required column or the **first** malformed cell aborts the whole run with a single located error
1695
+ (`error: missing required column "date" in header` / `error: … line N …`, exit `1`), because a trust
1696
+ reconciliation must **never silently partial-parse**. That strictness is correct for the audit, but on
1697
+ **file one** of a real broker's export it can read as "the tool is broken" with no way to see what the
1698
+ file *does* contain. `vh trust inspect` is the read-only companion that turns that dead end into a
1699
+ self-service fix.
1700
+
1701
+ ```
1702
+ vh trust inspect <file> --as <bank|ledger|rentroll> [--map <lf>=<hdr>] [--map-file <json>]
1703
+ [--bank-format csv|ofx] [--sample <n>] [--json]
1704
+ ```
1705
+
1706
+ `inspect` runs the **same parse primitives** `reconcile` uses, but on **one file**, **without failing
1707
+ closed**. It reports, for that file:
1708
+
1709
+ - the **detected format** (CSV vs OFX/QFX) and the **detected header columns** (or the OFX tags it read);
1710
+ - a **logical-field → header** map showing exactly which of your columns each required field bound to,
1711
+ with any unmapped **required** field flagged `(not found) [REQUIRED]`;
1712
+ - the **parse count** (`parsed: K OK of N data row(s)`) and a **sample** of the normalized records;
1713
+ - **EVERY** failing row (not just the first), each by data-row number with its reason; and
1714
+ - a **`how to fix:`** hint that, for each miss, names both the accepted column aliases **and** the
1715
+ `--map` override that loads the file as-is.
1716
+
1717
+ `inspect` **writes nothing** and **checks only PARSING** — it does **not** reconcile, match, compute the
1718
+ three balances, or attest anything. Its own output leads by saying so:
1719
+
1720
+ > `TrustLedger AIDS reconciliation; the broker remains the responsible custodian.`
1721
+ > ``inspect`` only checks that this file PARSES into the normalized model — it does NOT reconcile or
1722
+ > attest anything. To reconcile, run ``vh trust reconcile``.
1723
+
1724
+ That is the same honest posture as the disclaimer at the top of this document: a clean `inspect` means
1725
+ the file **loads**, not that the books are **right** — the three-way reconciliation, and a qualified
1726
+ CPA's review of the packet, still govern.
1727
+
1728
+ ### Exit codes (`vh trust inspect`)
1729
+
1730
+ | Exit | Meaning |
1731
+ | --- | --- |
1732
+ | `0` | **clean** — the file parses end to end: every required column found and every data row normalized |
1733
+ | `3` | **not clean** — a required column is missing OR at least one row failed to parse (the report still prints the header map, the good-row sample, and the `how to fix:` hint) |
1734
+ | `2` | usage error (missing `<file>`, missing/bad `--as`, bad `--map`/`--bank-format`, unknown flag, extra positional) |
1735
+ | `1` | IO error (the file is unreadable) |
1736
+
1737
+ Note the contrast with `reconcile`: a **malformed data file** makes `reconcile` exit `1`, but it makes
1738
+ `inspect` exit `3` — because for `inspect` a malformed file is the **expected** thing it was run to
1739
+ diagnose, not an IO failure. `--json` round-trips the full diagnostic report (header, `mapped`,
1740
+ `requiredMissing`, `rowCount`, `okCount`, `records`, `errors`, `sample`, plus `clean`/`code`/`hint`/
1741
+ `caveat`/`scope`), so onboarding can be scripted.
1742
+
1743
+ ### The column-mapping escape hatch: `--map` / `--map-file`
1744
+
1745
+ When a real export's header matches **none** of the built-in aliases (a bank column labelled
1746
+ `MoneyOut`, a rent-roll `Tenant Name`), you do **not** have to edit the source file. Point the parser at
1747
+ the right columns with an explicit map. The map **overrides** the alias auto-detect for the fields it
1748
+ names and leaves the rest to auto-detect — so a **one-field** map fixes a single stray header.
1749
+
1750
+ The **logical fields** you may map are the parser's own field names; an unknown key, or a header the
1751
+ file does not actually contain, is a **named error** that lists the valid options (never a silent
1752
+ mis-map):
1753
+
1754
+ - **bank / ledger:** `date`, `memo`, `type`, and **either** a signed `amount` **or** a `debit`/`credit`
1755
+ pair (`party`/`payee` on the ledger).
1756
+ - **rentroll:** `date`, `tenant`, and **either** `amount` **or** a `payment`/`charge` pair.
1757
+
1758
+ **Syntax differs by command**, because `reconcile` handles three files at once and `inspect` handles
1759
+ one:
1760
+
1761
+ | Command | `--map` form | `--map-file` shape |
1762
+ | --- | --- | --- |
1763
+ | `vh trust inspect` | `--map <logical>=<header>` (the source is `--as`) | `{ "<that --as source>": { "<logical>": "<header>" } }` |
1764
+ | `vh trust reconcile` | `--map <source>:<logical>=<header>` (`source` = `bank`\|`ledger`\|`rentroll`) | `{ "bank": { … }, "ledger": { … }, "rentroll": { … } }` |
1765
+
1766
+ Both flags are **repeatable**; a `--map-file` supplies the base and an inline `--map` overrides it on a
1767
+ clash. **How a bad map is reported splits two ways, and it differs between the commands:**
1768
+
1769
+ - A **structural** flag error — a malformed `--map` (no `=`, an empty side), an unreadable or invalid
1770
+ `--map-file`, an unknown source key — is a **usage error (exit `2`)** for **both** commands. It is a
1771
+ bad flag value caught before any file is parsed, the same exit class whether it arrives by inline
1772
+ `--map` or by `--map-file`, so CI can tell "fix your flags" from a real IO error.
1773
+ - A **semantic** map error — an **unknown logical field**, or a **mapped-to header that is absent from
1774
+ the file** — is where the two commands diverge. `reconcile` **pre-flights** every source's map
1775
+ (`validateColumnMapForSource`) and rejects these as a **usage error (exit `2`)** before reconciling.
1776
+ `inspect`, by contrast, feeds the map straight into the same `diagnoseSource` parse it is built to
1777
+ diagnose, so an unknown field or absent header surfaces as a **parse failure in the report and exits
1778
+ `3` (not clean)** — not `2`. That is deliberate: for `inspect` a map that does not line up with the
1779
+ file is exactly the kind of "this file does not parse as mapped" finding the command exists to show
1780
+ you, alongside the `how to fix:` hint, rather than a flag-usage abort.
1781
+
1782
+ ### Worked example: "my header isn't recognized → inspect → --map → it loads"
1783
+
1784
+ A broker's bank export uses house column names no alias matches (`When`, `Narrative`, `MoneyOut`,
1785
+ `MoneyIn`, `Kategorie`). Running `reconcile` on it dead-ends on the first required column it cannot find.
1786
+ **First, `inspect` to see what the file actually contains:**
1787
+
1788
+ ```
1789
+ $ vh trust inspect bank.csv --as bank; echo "exit=$?"
1790
+ # vh trust inspect — bank (bank.csv)
1791
+ TrustLedger AIDS reconciliation; the broker remains the responsible custodian.
1792
+ `inspect` only checks that this file PARSES into the normalized model — it does NOT reconcile or attest anything. To reconcile, run `vh trust reconcile`.
1793
+
1794
+ detected format: csv
1795
+ header columns (5): When, Narrative, MoneyOut, MoneyIn, Kategorie
1796
+
1797
+ logical field -> header column:
1798
+ date: (not found) [REQUIRED]
1799
+ ...
1800
+
1801
+ how to fix:
1802
+ - the "date" column was not found — rename your column to (or add) one named one of [date, posted, posting date, transaction date, trans date], OR map your existing header with --map date=<your header>
1803
+ exit=3
1804
+ ```
1805
+
1806
+ **Then follow the hint — map your existing headers, no source edit — and it loads:**
1807
+
1808
+ ```
1809
+ $ vh trust inspect bank.csv --as bank \
1810
+ --map date=When --map memo=Narrative --map debit=MoneyOut --map credit=MoneyIn --map type=Kategorie
1811
+ ... parsed: 4 OK of 4 data row(s)
1812
+ ... failures: none
1813
+ exit=0
1814
+ ```
1815
+
1816
+ The **same map** then drives `reconcile` (here via a reusable `--map-file`, so the three files' overrides
1817
+ live in one place):
1818
+
1819
+ ```
1820
+ $ cat maps.json
1821
+ { "bank": { "date":"When", "memo":"Narrative", "debit":"MoneyOut", "credit":"MoneyIn", "type":"Kategorie" } }
1822
+
1823
+ $ vh trust reconcile bank.csv ledger.csv rentroll.csv --map-file maps.json --out ./packets/may
1824
+ PASS: three-way reconciliation tie out (...)
1825
+ ```
1826
+
1827
+ This turns "hope their file matches our fixtures" into **"their file loads, or the tool tells them
1828
+ exactly how to make it load."**
1829
+
1830
+ ### Widened alias + date coverage (so many real exports load with NO map)
1831
+
1832
+ The mapping escape hatch is the fallback; the common cases are covered by **wider built-in aliases**
1833
+ drawn from the exports the target buyer actually uses, so a typical QuickBooks / bank / rent-roll export
1834
+ parses with **no `--map` at all**:
1835
+
1836
+ - **bank:** `Withdrawal`/`Withdrawal Amt.`/`Debit Amount` and `Deposit`/`Deposit Amt.`/`Credit Amount`
1837
+ split columns, a `Posting Date`/`Transaction Date`, a `Check #`/`Ref`, and a running-`Balance` column
1838
+ the parser ignores.
1839
+ - **QuickBooks ledger:** `Num`/`Clr`/`Split`/`Account` columns are tolerated, and the payee is read from
1840
+ `Name`/`Payee`.
1841
+ - **rent roll:** `Tenant`/`Resident`/`Lessee`/`Lease` (and `Name`), and either `Amount Paid`/`Payment`
1842
+ (a credit) or `Amount Due`/`Charge` (a debit), with a `Balance` column ignored. Note a two-word
1843
+ `Tenant Name` is **not** itself an alias — it is exactly the header the `--map` example below maps;
1844
+ the no-map headers are the single-word forms above.
1845
+
1846
+ Dates now parse beyond ISO `YYYY-MM-DD`, `M/D/YYYY`, and OFX `YYYYMMDD`: the textual forms
1847
+ `Mon DD, YYYY` (e.g. `Jan 5, 2024`, `September 5 2024`, `Sept. 30, 2024`) and `DD-Mon-YYYY` (e.g.
1848
+ `5-Jan-2024`, `05-Jan-24`) are accepted. Every date is still **calendar-validated** — `Feb 30, 2024` or
1849
+ an unknown month name is a **named error**, never coerced — keeping the parser strict even as it accepts
1850
+ more shapes.
1851
+
1852
+ > **A clean `inspect` is not a PASS.** `inspect` only confirms a file **parses**; it makes no
1853
+ > three-way, computes no balances, and attests nothing. The broker remains the legal trust-account
1854
+ > custodian, and a qualified CPA must still review the reconciliation **packet** — exactly as stated in
1855
+ > the disclaimer at the top of this document. `inspect`, `--map`, and the widened aliases change **how a
1856
+ > file gets in**, never **what a PASS means**.
1857
+
1858
+ ---
1859
+
1860
+ ## How it works (the pipeline)
1861
+
1862
+ ```
1863
+ ingest.js parse bank statement (CSV/OFX) + QuickBooks ledger + rent roll
1864
+ into NormalizedRecord[] (integer cents, no float drift)
1865
+ |
1866
+ match.js pair bank <-> book lines (exact + fuzzy + split)
1867
+ |
1868
+ reconcile.js the three-balance check + the classified exception list
1869
+ |
1870
+ report.js render a DATED, deterministic, audit-ready packet (HTML + CSV)
1871
+ |
1872
+ cli.js `vh trust reconcile` — one-line PASS/FAIL + CI-gateable exit code
1873
+ (`--seal` emits a tamper-evident seal alongside the packet)
1874
+ `vh trust inspect` — read-only parse diagnostic over ONE file
1875
+ (same parse primitives; never fails closed)
1876
+ `vh trust verify-seal` — read-only OFFLINE seal verify (re-derives the
1877
+ root; ACCEPTED/REJECTED + per-file localization)
1878
+ |
1879
+ seal.js pure, I/O-free, byte-deterministic seal over the inputs + packet + verdict/role
1880
+ header, REUSING cli/core/manifest.js + cli/hash.js verbatim (no new hashing scheme)
1881
+ ```
1882
+
1883
+ Each stage is a pure, deterministic module under `trustledger/`. `report.buildPacket(...)` is the pure
1884
+ heart: it takes the three normalized record sets and an explicit `reportDate`, runs match + reconcile,
1885
+ and returns a JSON-serializable, order-stable model that the HTML/CSV renderers turn into the packet.
1886
+ There is no hidden clock and no network.
1887
+
1888
+ ---
1889
+
1890
+ ## What stays a human step
1891
+
1892
+ TrustLedger BUILDS and locally TESTS the reconciliation engine. The steps that turn a correct engine
1893
+ into a sellable, compliant product are **human-owned** and tracked in STRATEGY.md (Proposals › **P-5**):
1894
+
1895
+ - **CPA / counsel sign-off** on the disclaimer wording and on the explicit statement that a PASS does
1896
+ not imply legal compliance (P-5 #1). That review now starts from **"run this to confirm the gate is
1897
+ correct"** instead of **"trust our disclaimer"**: have the reviewer run **`vh trust corpus`** (see
1898
+ **The correctness corpus** above) and watch the gate **FAIL** each canonical out-of-trust fraud and
1899
+ **PASS** its benign twin, through the same engine path the real `reconcile` exit uses — a faster,
1900
+ higher-confidence human action than reading prose. The corpus **confirms the gate's behaviour**; it does
1901
+ **not** certify a jurisdiction or constitute legal advice, so the CPA/counsel sign-off it informs is
1902
+ unchanged. The deliverable that review attaches to is also a **SEALED, independently-verifiable
1903
+ artifact**: `--seal` + `verify-seal` (see **Sealing the packet** above) make the audit packet
1904
+ tamper-evident, so the CPA/counsel reviews a packet an examiner can confirm byte-for-byte rather than an
1905
+ editable printout. The human trust-root for "**sealed on date T**" (a signing key and/or trusted
1906
+ timestamp) stays P-3 and is **needs-human** — the seal proves tamper-evidence only, never a timestamp or
1907
+ a legal opinion.
1908
+ - **Fill in + have counsel sign the per-state policy TABLE.** The engine **already consumes** a
1909
+ reviewed policy as data (see **The per-state policy layer** above) — the human task is now narrow:
1910
+ fill in `trustledger/fixtures/policy/<state>.json` in the shipped, validated format (the
1911
+ `severities` overrides + their statute `citations`) and have a CPA/counsel sign that mapping for the
1912
+ jurisdiction. No engine change is needed; the bundled `baseline.json` / `ca-example.json` are the
1913
+ DRAFT skeletons to copy (P-5 #2).
1914
+ - **Run the two-month design-partner script with 1–2 brokers** (e.g. via NARPM). The concrete,
1915
+ decision-ready validation is a script the engine already supports — and it now **leads with the
1916
+ de-risked onboarding step on the surface a non-technical broker actually uses, the BROWSER**, so a
1917
+ real export's first contact with the tool is "it loads, or the tool tells you how," not a dead-end
1918
+ parse error and not a terminal command the buyer will never run:
1919
+ 1. **FIRST** have the partner open `vh trust serve` **in their browser** and **drop each real file**:
1920
+ if it does not load, the page shows that file's columns and lets the broker **map** the missing
1921
+ field from a dropdown of its actual headers, then re-checks it — the **in-browser inspect/map UI**
1922
+ (see **In-browser onboarding: inspect & map a file that won't load** above). This is the same
1923
+ `diagnoseSource` self-service fix as the CLI `vh trust inspect <eachFile> --as <type>` /
1924
+ `--map <logical>=<header>` (still available for technical users), but it requires **no terminal** —
1925
+ closing the gap between "the buyer who will never use a terminal" and an onboarding step that used
1926
+ to require one. It converts the single most likely pilot-killer — ingest choking on a real broker's
1927
+ export — from a dead end into a self-service fix **before** any reconciliation runs.
1928
+ 2. **THEN** run the two-month reconcile script: have the partner run
1929
+ `vh trust reconcile … --state <code> --emit-close month1.json` on their **real month-1** files, then
1930
+ re-run on **month-2** files with `--prior-close month1.json`, and confirm (a) the three balances tie
1931
+ out both months, (b) the roll-forward is clean (no `CONTINUITY_BREAK`), and (c) the exceptions read
1932
+ correctly.
1933
+
1934
+ That **two-month run IS the willingness-to-pay validation** — it shows the recurring monthly product
1935
+ working past month one, which a single-period demo cannot; leading with the **browser** inspect/map UI
1936
+ makes sure month one even gets that far **without the broker ever touching a terminal** (P-5 #3). The
1937
+ measured WTP figure comes from **`vh trust value-proof`** (see **The value-proof** above): run it on a
1938
+ month the broker **already closed by hand and signed off**, and it prints the exact dollars the gate
1939
+ caught that the manual close **let through** (`out_of_trust_missed`), or an honest "fix your data and
1940
+ re-run" (`data_gap_only`), or a signed clean confirmation (`clean_confirmed`) — turning "their
1941
+ willingness to keep using it is the WTP signal" into a number a broker reads on their **own** data. The
1942
+ value-proof **compares the gate to the manual close**; it does **not** certify a jurisdiction or
1943
+ constitute legal advice, so the CPA/counsel sign-off it informs (P-5 #1) is unchanged.
1944
+
1945
+ **Zero-install variant of the two-month step.** If the partner will not install anything at all,
1946
+ the sharpened ask's step (2) is amendable to **"or hand them the offline app"**: email the ONE file
1947
+ [`trustledger/dist/trustledger-standalone.html`](../trustledger/dist/trustledger-standalone.html)
1948
+ (see **Zero-install: the offline app** above) and have them drag the same real month-1 and month-2
1949
+ files onto the page. The offline app delivers step (2)'s **(a)** both months tie out and **(c)** the
1950
+ exceptions read correctly — as **two INDEPENDENT monthly tie-outs**, FREE and zero-install; but its
1951
+ UI has only the three file pickers, so the **machine-checked roll-forward of step (2)(b)** (no
1952
+ `CONTINUITY_BREAK` via `--emit-close` / `--prior-close`) is **not** part of the offline surface and
1953
+ stays an **installed-CLI** capability. So the zero-install variant changes the **delivery** of the
1954
+ free tie-out surface **and narrows it** to the two independent monthly tie-outs (dropping the
1955
+ continuity check). The CPA/counsel review (P-5 #1), the per-state policy table (P-5 #2), and the
1956
+ design-partner WTP read (P-5 #3) stay exactly the human steps listed here, unchanged.
1957
+
1958
+ - **Deploying the web front-door.** `vh trust serve` runs the broker-facing browser UI **locally**
1959
+ (localhost only by default). Exposing it to others — behind **your** nginx/Cloudflare on **your** own
1960
+ domain with TLS and access control — is a human deploy step (see **The web front-door** above). The
1961
+ loop never auto-deploys it and never binds anything but localhost by default.
1962
+
1963
+ Hosting, billing (a SaaS subscription), and pricing are likewise human steps. Income comes from selling
1964
+ the product to paying customers — **never** from a token, coin, sale, or yield scheme.
1965
+
1966
+ ---
1967
+
1968
+ ## See also
1969
+
1970
+ - [`docs/TRUST-BOUNDARIES.md`](TRUST-BOUNDARIES.md) — the project-wide trust posture.
1971
+ - [`docs/DATALEDGER.md`](DATALEDGER.md) and [`docs/PROOFPARCEL.md`](PROOFPARCEL.md) — the sibling
1972
+ products on the shared provenance core.
1973
+
1974
+
1975
+ ---
1976
+ <sub>© 2026 verifyhash.com · Licensed under Apache-2.0 (SPDX-License-Identifier: Apache-2.0) — see the [LICENSE](https://verifyhash.com/LICENSE) and [NOTICE](https://verifyhash.com/NOTICE) served with this file.</sub>