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.
- package/LICENSE +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- 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>
|