verifyhash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. package/trustledger/valueproof.js +350 -0
@@ -0,0 +1,3179 @@
1
+ "use strict";
2
+
3
+ // TrustLedger — cli.js
4
+ //
5
+ // T-22.4: `vh trust reconcile <bank> <ledger> <rentroll> [--out <dir>]`.
6
+ //
7
+ // T-27.3: `vh trust serve [--port <n>] [--host <h>]` launches the LOCAL web
8
+ // front-door (trustledger/server.js) over this same engine so a non-technical
9
+ // broker can drop the three files in a browser. Files are processed in-memory;
10
+ // nothing is persisted server-side. Exposing it (nginx/Cloudflare on the broker's
11
+ // own domain) is a HUMAN deploy step — it is never auto-deployed.
12
+ //
13
+ // T-26.2: `vh trust reconcile ... --out <dir> --seal [<file>]` additionally emits a
14
+ // TAMPER-EVIDENT reconciliation seal AFTER the packet (binding the 3 source inputs +
15
+ // every emitted packet file, and the emitted close when --emit-close is used). The CLI
16
+ // does all the file READING and hands seal.js already-loaded { relPath, bytes } entries
17
+ // (seal.js stays pure). `--seal` REQUIRES `--out` (no emitted packet, nothing to seal).
18
+ // `vh trust verify-seal <sealfile> [--dir <d>] [--json]` independently RE-DERIVES each
19
+ // sealed file from disk OFFLINE (no key, no network) and prints ACCEPTED (0) only when
20
+ // EVERY file matches, else REJECTED (3) with the per-file CHANGED/MISSING/UNEXPECTED list.
21
+ //
22
+ // The one command a broker runs: hand it the three files they already have every
23
+ // month and it runs the WHOLE pipeline end to end —
24
+ //
25
+ // ingest (parse the bank statement, the QuickBooks ledger, the rent roll)
26
+ // -> match (pair bank<->book lines)
27
+ // -> reconcile (the three-balance check + classified exceptions)
28
+ // -> report (a DATED, deterministic, audit-ready HTML + CSV packet)
29
+ //
30
+ // and prints a single PASS/FAIL line with a CI-gateable exit code.
31
+ //
32
+ // PER-STATE POLICY (T-23.2): pass `--state <code>` to score under a bundled
33
+ // per-state trust-rule policy, or `--policy <file>` for an explicit one. The
34
+ // policy overrides exception severities (e.g. a state that makes an NSF reversal
35
+ // a hard ERROR) BEFORE the PASS/FAIL verdict and exit code are computed, so the
36
+ // gate reflects the REVIEWED severities. With neither flag the built-in baseline
37
+ // is used (byte-for-byte unchanged). Supplying both, or an unknown `--state`, is
38
+ // a usage error (exit 2). The packet names which policy governed the run and
39
+ // surfaces each override's citation; the policy itself is still a DRAFT a CPA/
40
+ // counsel must review (it is NOT legal advice).
41
+ //
42
+ // FILESYSTEM HYGIENE: side-effect files (the packet) are written ONLY to the
43
+ // caller-chosen --out directory — never silently to cwd. Without --out the
44
+ // command prints the summary + the report to stdout and writes NOTHING, so it is
45
+ // safe to run anywhere (and trivially CI-pipeable). The exit code is a stable,
46
+ // documented contract: 0 = PASS (ties out, no error-severity finding),
47
+ // 3 = FAIL (does not tie out, or an out-of-trust finding), 2 = usage error,
48
+ // 1 = an input/IO error (e.g. an unreadable or malformed file).
49
+
50
+ const fs = require("fs");
51
+ const path = require("path");
52
+
53
+ const ingest = require("./ingest");
54
+ const report = require("./report");
55
+ const corpus = require("./corpus");
56
+ const valueproof = require("./valueproof");
57
+ const policy = require("./policy");
58
+ const close = require("./close");
59
+ const seal = require("./seal");
60
+ const server = require("./server");
61
+ const license = require("./license");
62
+ const plans = require("./plans");
63
+
64
+ // The three reconcile sources, keyed by the broker-facing label used on the
65
+ // command line (`--map <source>:<logical>=<header>` and the --map-file top-level
66
+ // keys). Shared with inspect's --as so the two commands name sources identically.
67
+ const MAP_SOURCES = Object.freeze(["bank", "ledger", "rentroll"]);
68
+
69
+ // Parse ONE `--map` value into { source?, logical, header }. Accepts either
70
+ // "<logical>=<header>" (inspect: the source is implied by --as), or
71
+ // "<source>:<logical>=<header>" (reconcile: which of the three files).
72
+ // Malformed syntax (no "=", an empty logical/header, or an unknown source
73
+ // prefix) is a USAGE error (exit 2) — a bad flag value, surfaced clearly.
74
+ function parseMapArg(raw, { requireSource } = {}) {
75
+ const s = String(raw == null ? "" : raw);
76
+ const eq = s.indexOf("=");
77
+ if (eq === -1) {
78
+ const e = new Error(
79
+ `--map must be <logical>=<header>${
80
+ requireSource ? " (prefixed <source>:<logical>=<header>)" : ""
81
+ } (got "${raw}")`
82
+ );
83
+ e.usage = true;
84
+ throw e;
85
+ }
86
+ let lhs = s.slice(0, eq).trim();
87
+ const header = s.slice(eq + 1); // header kept verbatim (may contain spaces)
88
+ let source;
89
+ const colon = lhs.indexOf(":");
90
+ if (colon !== -1) {
91
+ source = lhs.slice(0, colon).trim().toLowerCase();
92
+ lhs = lhs.slice(colon + 1).trim();
93
+ if (!MAP_SOURCES.includes(source)) {
94
+ const e = new Error(
95
+ `--map source must be one of ${MAP_SOURCES.join("|")} (got "${source}")`
96
+ );
97
+ e.usage = true;
98
+ throw e;
99
+ }
100
+ }
101
+ if (requireSource && source === undefined) {
102
+ const e = new Error(
103
+ `--map for reconcile must be <source>:<logical>=<header> ` +
104
+ `(source one of ${MAP_SOURCES.join("|")}) (got "${raw}")`
105
+ );
106
+ e.usage = true;
107
+ throw e;
108
+ }
109
+ const logical = lhs;
110
+ if (logical === "" || String(header).trim() === "") {
111
+ const e = new Error(
112
+ `--map must be <logical>=<header> with both sides non-empty (got "${raw}")`
113
+ );
114
+ e.usage = true;
115
+ throw e;
116
+ }
117
+ return { source, logical, header };
118
+ }
119
+
120
+ // Read + parse a `--map-file <json>`: a `{ bank|ledger|rentroll: { <logical>:
121
+ // <header> } }` per-source mapping. An unreadable file or malformed JSON, a
122
+ // non-object body, an unknown top-level source key, or a non-string mapping
123
+ // value is a USAGE error (exit 2). Returns { bank?, ledger?, rentroll? } where
124
+ // each value is a plain `{ <logical>: <header> }` columnMap.
125
+ function readMapFile(file) {
126
+ let text;
127
+ try {
128
+ text = fs.readFileSync(path.resolve(file), "utf8");
129
+ } catch (e) {
130
+ const err = new Error(`cannot read --map-file ${file}: ${e.message}`);
131
+ err.usage = true;
132
+ throw err;
133
+ }
134
+ let obj;
135
+ try {
136
+ obj = JSON.parse(text);
137
+ } catch (e) {
138
+ const err = new Error(`invalid JSON in --map-file ${file}: ${e.message}`);
139
+ err.usage = true;
140
+ throw err;
141
+ }
142
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
143
+ const err = new Error(
144
+ `--map-file ${file} must be a JSON object of { bank|ledger|rentroll: {logical: header} }`
145
+ );
146
+ err.usage = true;
147
+ throw err;
148
+ }
149
+ const out = {};
150
+ for (const [src, map] of Object.entries(obj)) {
151
+ if (!MAP_SOURCES.includes(src)) {
152
+ const err = new Error(
153
+ `--map-file ${file}: unknown source key "${src}" (expected one of ${MAP_SOURCES.join(
154
+ ", "
155
+ )})`
156
+ );
157
+ err.usage = true;
158
+ throw err;
159
+ }
160
+ if (map == null || typeof map !== "object" || Array.isArray(map)) {
161
+ const err = new Error(
162
+ `--map-file ${file}: "${src}" must map to an object of {logical: header}`
163
+ );
164
+ err.usage = true;
165
+ throw err;
166
+ }
167
+ const cm = {};
168
+ for (const [logical, header] of Object.entries(map)) {
169
+ if (typeof header !== "string" || header.trim() === "") {
170
+ const err = new Error(
171
+ `--map-file ${file}: ${src}.${logical} must be a non-empty header string`
172
+ );
173
+ err.usage = true;
174
+ throw err;
175
+ }
176
+ cm[logical] = header;
177
+ }
178
+ out[src] = cm;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ // Merge per-source maps: --map-file provides the base, individual --map entries
184
+ // OVERRIDE it (last-write-wins for a repeated logical). Returns { <source>:
185
+ // columnMap } using only sources that have at least one mapping. PURE.
186
+ function buildSourceMaps(mapFileMaps, mapArgs) {
187
+ const out = {};
188
+ for (const src of MAP_SOURCES) {
189
+ if (mapFileMaps && mapFileMaps[src]) out[src] = { ...mapFileMaps[src] };
190
+ }
191
+ for (const { source, logical, header } of mapArgs) {
192
+ if (!out[source]) out[source] = {};
193
+ out[source][logical] = header;
194
+ }
195
+ return out;
196
+ }
197
+
198
+ // Exit codes — shared, documented contract (mirrors the dataset/parcel gates:
199
+ // 0 PASS, 3 data/gate FAIL, 2 usage, 1 IO/input error).
200
+ //
201
+ // DATA_GAP (4) is value-proof-ONLY (T-45.2): it distinguishes the "fix-my-data
202
+ // and re-run" value-proof outcome from a genuine out-of-trust FAIL (3) so a CI
203
+ // pipeline can gate on the make-or-break difference. The reconcile gate itself
204
+ // still uses only 0/3 — this code never changes a reconcile verdict or exit.
205
+ const EXIT = Object.freeze({ PASS: 0, IO: 1, USAGE: 2, FAIL: 3, DATA_GAP: 4 });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Argument parsing
209
+ // ---------------------------------------------------------------------------
210
+
211
+ // Parse `reconcile` argv into options. Three positional files (bank, ledger,
212
+ // rentroll) in order, plus flags. Unknown flags and missing positionals are
213
+ // reported by the caller as usage errors.
214
+ function parseReconcileArgs(argv) {
215
+ const opts = {
216
+ bank: undefined,
217
+ ledger: undefined,
218
+ rentroll: undefined,
219
+ out: undefined,
220
+ json: false,
221
+ date: undefined, // override the report date (default: today); MUST be YYYY-MM-DD
222
+ period: undefined, // optional human label for the statement period
223
+ openingBank: 0,
224
+ openingBook: 0,
225
+ toleranceCents: 0,
226
+ bankFormat: undefined, // force "csv" | "ofx" for the bank file
227
+ policyFile: undefined, // explicit per-state policy file (--policy <file>)
228
+ state: undefined, // bundled per-state policy by its state code (--state <code>)
229
+ priorClose: undefined, // prior period's close.json to roll forward FROM (--prior-close <file>)
230
+ emitClose: undefined, // path to write THIS run's close.json TO (--emit-close <file>)
231
+ seal: false, // --seal [<file>] given at all (T-26.2): emit a reconciliation seal
232
+ sealFile: undefined, // caller-named seal path; undefined => default name under --out
233
+ license: undefined, // path to a signed *.vhlicense.json that UNLOCKS the paid surfaces (T-29.2)
234
+ vendor: undefined, // 0x-address the license issuer is pinned to (T-29.2)
235
+ mapArgs: [], // repeatable --map <source>:<logical>=<header> (T-25.3)
236
+ mapFile: undefined, // --map-file <json> per-source column maps (T-25.3)
237
+ _positionals: [],
238
+ };
239
+ for (let i = 0; i < argv.length; i++) {
240
+ const a = argv[i];
241
+ switch (a) {
242
+ case "--out":
243
+ opts.out = argv[++i];
244
+ break;
245
+ case "--json":
246
+ opts.json = true;
247
+ break;
248
+ case "--date":
249
+ opts.date = argv[++i];
250
+ break;
251
+ case "--period":
252
+ opts.period = argv[++i];
253
+ break;
254
+ case "--opening-bank":
255
+ opts.openingBank = parseCentsArg(argv[++i], "--opening-bank");
256
+ opts.openingBankSet = true;
257
+ break;
258
+ case "--opening-book":
259
+ opts.openingBook = parseCentsArg(argv[++i], "--opening-book");
260
+ opts.openingBookSet = true;
261
+ break;
262
+ case "--tolerance-cents":
263
+ opts.toleranceCents = parseIntArg(argv[++i], "--tolerance-cents");
264
+ break;
265
+ case "--bank-format":
266
+ opts.bankFormat = argv[++i];
267
+ break;
268
+ case "--policy":
269
+ opts.policyFile = argv[++i];
270
+ break;
271
+ case "--state":
272
+ opts.state = argv[++i];
273
+ break;
274
+ case "--license":
275
+ opts.license = argv[++i];
276
+ break;
277
+ case "--vendor":
278
+ opts.vendor = argv[++i];
279
+ break;
280
+ case "--prior-close":
281
+ opts.priorClose = argv[++i];
282
+ break;
283
+ case "--emit-close":
284
+ opts.emitClose = argv[++i];
285
+ break;
286
+ case "--seal": {
287
+ // --seal takes an OPTIONAL <file>. Peek the next token: consume it as the
288
+ // caller-named seal path ONLY when it exists and is not another flag. The
289
+ // three positional files conventionally precede the flags, so this does not
290
+ // swallow them; without a value the seal lands at a default name under --out.
291
+ opts.seal = true;
292
+ const next = argv[i + 1];
293
+ if (next !== undefined && !String(next).startsWith("--")) {
294
+ opts.sealFile = next;
295
+ i++;
296
+ }
297
+ break;
298
+ }
299
+ case "--map":
300
+ // reconcile: source is REQUIRED (three files), so <source>:<logical>=<header>.
301
+ opts.mapArgs.push(parseMapArg(argv[++i], { requireSource: true }));
302
+ break;
303
+ case "--map-file":
304
+ opts.mapFile = argv[++i];
305
+ break;
306
+ default:
307
+ if (a && a.startsWith("--")) {
308
+ const e = new Error(`unknown option: ${a}`);
309
+ e.usage = true;
310
+ throw e;
311
+ }
312
+ opts._positionals.push(a);
313
+ }
314
+ }
315
+ [opts.bank, opts.ledger, opts.rentroll] = opts._positionals;
316
+ return opts;
317
+ }
318
+
319
+ function parseCentsArg(raw, flag) {
320
+ // Reuse ingest's exact dollar->cents parser so --opening-bank "1,234.56" works
321
+ // identically to a file amount (no float drift).
322
+ try {
323
+ return ingest.parseCents(raw, flag);
324
+ } catch (e) {
325
+ const err = new Error(`${flag}: ${e.message}`);
326
+ err.usage = true;
327
+ throw err;
328
+ }
329
+ }
330
+
331
+ function parseIntArg(raw, flag) {
332
+ if (!/^\d+$/.test(String(raw || ""))) {
333
+ const err = new Error(`${flag} must be a non-negative integer (cents)`);
334
+ err.usage = true;
335
+ throw err;
336
+ }
337
+ return Number(raw);
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // The license GATE for the paid reconcile surfaces (T-29.2).
342
+ //
343
+ // Maps each requested PAID flag to the entitlement it needs, then — only when at
344
+ // least one paid surface is requested — REQUIRES a valid, vendor-pinned license
345
+ // carrying every needed entitlement. Pure of I/O except a single offline read of
346
+ // the caller-chosen license file; holds NO signing key (verify is key-free), no
347
+ // network. Returns { code } — EXIT.PASS to proceed, EXIT.USAGE to refuse — and
348
+ // writes the precise, ACTIONABLE reason to writeErr. Never throws on an ordinary
349
+ // refusal (a malformed flag is the only caller error, surfaced as a usage line).
350
+ //
351
+ // The reason is reported EXACTLY as `verifyLicense` returns it (wrong_issuer /
352
+ // expired / not_yet_valid / bad_signature / malformed) so a refusal is never
353
+ // ambiguous, and a wrong/expired license NEVER silently downgrades to a free run.
354
+ // ---------------------------------------------------------------------------
355
+
356
+ // Which entitlement each paid reconcile surface requires. The ONLY place the
357
+ // flag->entitlement mapping lives, so the gate and the help can never drift.
358
+ const PAID_FEATURE_ENTITLEMENTS = Object.freeze([
359
+ {
360
+ requested: (o) => o.state != null || o.policyFile != null,
361
+ entitlement: "multi_state_policy",
362
+ label: "multi-state policy packs (--state/--policy)",
363
+ },
364
+ {
365
+ requested: (o) => o.seal === true,
366
+ entitlement: "seal",
367
+ label: "the tamper-evident reconciliation seal (--seal)",
368
+ },
369
+ ]);
370
+
371
+ function gateReconcile(opts, reportDate, writeErr) {
372
+ // Which paid features were requested in THIS run?
373
+ const needed = PAID_FEATURE_ENTITLEMENTS.filter((f) => f.requested(opts));
374
+ if (needed.length === 0) {
375
+ // FREE TIER. No paid surface requested: proceed UNCHANGED. (A stray --license/
376
+ // --vendor with no paid feature is simply ignored — it costs nothing and keeps
377
+ // the free path byte-for-byte identical.)
378
+ return { code: EXIT.PASS };
379
+ }
380
+
381
+ const featureList = needed.map((f) => f.label).join(" and ");
382
+ const hasLicense = opts.license != null;
383
+ const hasVendor = opts.vendor != null;
384
+
385
+ // Both license sources must be present together — a license file is worthless
386
+ // without the vendor key to PIN it to, and a vendor with no file is nothing to
387
+ // verify. Either alone is a usage error (parser parity with --key-env/--key-file).
388
+ if (!hasLicense && !hasVendor) {
389
+ writeErr(
390
+ `error: ${featureList} ${needed.length > 1 ? "are" : "is"} a PAID feature and ` +
391
+ "requires a license; pass --license <file> --vendor <0xaddr> " +
392
+ "(mint one with `vh trust license issue`, verify it with `vh trust license verify`). " +
393
+ "The FREE tier — baseline-policy reconcile + `vh trust inspect` — needs no license.\n"
394
+ );
395
+ return { code: EXIT.USAGE };
396
+ }
397
+ if (!hasLicense || !hasVendor) {
398
+ writeErr(
399
+ "error: --license and --vendor must be supplied together (a license file is " +
400
+ "verified by pinning it to the vendor key); pass BOTH --license <file> --vendor <0xaddr>\n"
401
+ );
402
+ return { code: EXIT.USAGE };
403
+ }
404
+
405
+ // Read the license file OFFLINE (the only I/O). An unreadable/garbled file is a
406
+ // usage error with a key-free message (there is no key in a license anyway).
407
+ let container;
408
+ try {
409
+ const text = fs.readFileSync(path.resolve(opts.license), "utf8");
410
+ container = license.readLicense(text);
411
+ } catch (e) {
412
+ writeErr(`error: cannot read --license file ${opts.license}: ${e.message}\n`);
413
+ return { code: EXIT.USAGE };
414
+ }
415
+
416
+ // Verify OFFLINE against the pinned vendor, dated at the run's reportDate. A
417
+ // malformed --vendor is a caller error thrown by verifyLicense — surface it as a
418
+ // usage line, never as a crash, and never echoing anything sensitive.
419
+ let verdict;
420
+ try {
421
+ verdict = license.verifyLicense(container, { now: reportDate, vendorAddress: opts.vendor });
422
+ } catch (e) {
423
+ writeErr(`error: ${e.message}\n`);
424
+ return { code: EXIT.USAGE };
425
+ }
426
+
427
+ if (!verdict.valid) {
428
+ // Report the precise reason verifyLicense returned — never silently downgrade.
429
+ writeErr(
430
+ `error: ${featureList} requires a VALID license, but the supplied license is ` +
431
+ `INVALID (reason: ${verdict.reason}). It does NOT unlock the paid surface; ` +
432
+ "the FREE baseline reconcile remains available without --state/--policy/--seal.\n"
433
+ );
434
+ return { code: EXIT.USAGE };
435
+ }
436
+
437
+ // Valid + in-window + correct issuer. Now require EACH requested feature's
438
+ // entitlement to actually be granted. A valid license that does not carry the
439
+ // entitlement still REFUSES (it never grants a feature it was not sold).
440
+ for (const f of needed) {
441
+ if (!license.hasEntitlement(verdict, f.entitlement)) {
442
+ writeErr(
443
+ `error: the supplied license is valid but does NOT include the "${f.entitlement}" ` +
444
+ `entitlement needed for ${f.label}; this license grants only ` +
445
+ `[${verdict.entitlements.join(", ")}]. The FREE baseline reconcile remains available.\n`
446
+ );
447
+ return { code: EXIT.USAGE };
448
+ }
449
+ }
450
+
451
+ return { code: EXIT.PASS };
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // The pipeline runner (pure of argv; takes resolved options + an injectable
456
+ // today() so the CLI passes a real date while tests pass a fixed one).
457
+ // ---------------------------------------------------------------------------
458
+
459
+ // runReconcile reads the three files, runs the pipeline, optionally writes the
460
+ // packet, and returns { code, model, summary, written, render }.
461
+ // opts: { bank, ledger, rentroll, out, json, date, period,
462
+ // openingBank, openingBook, toleranceCents, bankFormat }
463
+ // io: { write, writeErr, today } injectable; defaults to process + a real
464
+ // "YYYY-MM-DD" today only when no explicit --date was given.
465
+ function runReconcile(opts, io = {}) {
466
+ const write = io.write || ((s) => process.stdout.write(s));
467
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
468
+
469
+ if (!opts.bank || !opts.ledger || !opts.rentroll) {
470
+ writeErr(
471
+ "error: `vh trust reconcile` requires three files: <bank> <ledger> <rentroll>\n"
472
+ );
473
+ return { code: EXIT.USAGE };
474
+ }
475
+
476
+ // `--seal` seals the EMITTED packet, so there must be a packet on disk to seal.
477
+ // Without --out the command writes NOTHING (it streams to stdout), so --seal has
478
+ // nothing to bind — that is a usage error, surfaced with the fix, not silently
479
+ // ignored (parser parity with --out's other dependents).
480
+ if (opts.seal && !opts.out) {
481
+ writeErr(
482
+ "error: --seal requires --out (there is no emitted packet to seal without a " +
483
+ "--out <dir>); pass --out <dir> [--seal [<file>]]\n"
484
+ );
485
+ return { code: EXIT.USAGE };
486
+ }
487
+
488
+ // Report date: explicit --date wins (keeps output reproducible); else today.
489
+ // The function never calls `new Date()` itself when a date is provided, so a
490
+ // test can pin it; the CLI supplies today via io.today.
491
+ let reportDate = opts.date;
492
+ if (reportDate == null) {
493
+ reportDate = (io.today || todayISO)();
494
+ }
495
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(reportDate || ""))) {
496
+ writeErr(`error: --date must be "YYYY-MM-DD" (got "${reportDate}")\n`);
497
+ return { code: EXIT.USAGE };
498
+ }
499
+
500
+ // -- LICENSE GATE (T-29.2). Decide BEFORE any data work. -------------------
501
+ // The FREE tier — baseline-policy reconcile (no --state/--policy), no --seal —
502
+ // needs NO license and behaves byte-for-byte as before so a broker can evaluate
503
+ // the product before buying. The moment a PAID surface is requested
504
+ // (multi-state policy via --state/--policy, or the tamper-evident --seal), a
505
+ // VALID, in-window, vendor-pinned license carrying the matching entitlement is
506
+ // REQUIRED. A missing/expired/wrong-issuer/under-entitled license REFUSES with
507
+ // the precise reason (exit 2, a clear gate) and never silently downgrades to a
508
+ // free result. `now` is the resolved reportDate (the SAME injectable clock the
509
+ // packet is dated under) so verification is offline + deterministic; this path
510
+ // is read-only, holds NO key, and touches NO network.
511
+ const gate = gateReconcile(opts, reportDate, writeErr);
512
+ if (gate.code !== EXIT.PASS) return { code: gate.code };
513
+
514
+ // -- Resolve the per-state trust-rule policy (if any). ---------------------
515
+ // `--policy <file>` reads an explicit file; `--state <code>` resolves a bundled
516
+ // fixture by its state code. Supplying BOTH is ambiguous, and an unknown
517
+ // `--state` is unactionable — both are USAGE errors (exit 2), as is a malformed
518
+ // or unreadable policy file (a bad flag value, not a data-file IO error). With
519
+ // neither flag the run uses the built-in baseline severities (policy = null),
520
+ // which is byte-for-byte today's behaviour.
521
+ let activePolicy = null;
522
+ if (opts.policyFile != null && opts.state != null) {
523
+ writeErr(
524
+ "error: --policy and --state are mutually exclusive (choose an explicit " +
525
+ "policy file OR a bundled state code, not both)\n"
526
+ );
527
+ return { code: EXIT.USAGE };
528
+ }
529
+ if (opts.state != null) {
530
+ try {
531
+ activePolicy = policy.resolveState(opts.state);
532
+ } catch (e) {
533
+ writeErr(`error: ${e.message}\n`);
534
+ return { code: EXIT.USAGE };
535
+ }
536
+ } else if (opts.policyFile != null) {
537
+ let policyText;
538
+ try {
539
+ policyText = fs.readFileSync(path.resolve(opts.policyFile), "utf8");
540
+ } catch (e) {
541
+ writeErr(`error: cannot read --policy file ${opts.policyFile}: ${e.message}\n`);
542
+ return { code: EXIT.USAGE };
543
+ }
544
+ try {
545
+ activePolicy = policy.readPolicy(policyText);
546
+ } catch (e) {
547
+ writeErr(`error: invalid --policy file ${opts.policyFile}: ${e.message}\n`);
548
+ return { code: EXIT.USAGE };
549
+ }
550
+ }
551
+
552
+ // -- Resolve the prior period's close (--prior-close), if any. -------------
553
+ // Mirrors how --policy is handled: a malformed/unreadable close is a USAGE
554
+ // error (exit 2) — a BAD FLAG VALUE, not a data-file IO error.
555
+ //
556
+ // SEED-then-OVERRIDE. When present, the prior close's `ending` SEEDS this run's
557
+ // opening balances. An explicit --opening-bank/--opening-book then acts as an
558
+ // explicit OVERRIDE of that seed. BUILDER'S CHOICE (documented): a disagreeing
559
+ // override is HONORED but NOTED — we let the broker open where they say (e.g. a
560
+ // documented mid-period adjustment), AND we surface the disagreement on stderr,
561
+ // AND — crucially — the continuity check then compares the OPENING actually used
562
+ // against the prior ending, so a disagreeing override that breaks the chain
563
+ // SHOWS UP as a CONTINUITY_BREAK in the packet (flipping the verdict) rather than
564
+ // being silently swallowed. This is strictly safer than honoring it invisibly:
565
+ // the gap is recorded in the signed packet, not hidden behind a one-line warning.
566
+ let priorClose = null;
567
+ let openingNotes = [];
568
+ if (opts.priorClose != null) {
569
+ let closeText;
570
+ try {
571
+ closeText = fs.readFileSync(path.resolve(opts.priorClose), "utf8");
572
+ } catch (e) {
573
+ writeErr(
574
+ `error: cannot read --prior-close file ${opts.priorClose}: ${e.message}\n`
575
+ );
576
+ return { code: EXIT.USAGE };
577
+ }
578
+ try {
579
+ priorClose = close.readClose(closeText);
580
+ } catch (e) {
581
+ writeErr(
582
+ `error: invalid --prior-close file ${opts.priorClose}: ${e.message}\n`
583
+ );
584
+ return { code: EXIT.USAGE };
585
+ }
586
+
587
+ // Seed each leg from the prior ending UNLESS the broker explicitly overrode it.
588
+ if (!opts.openingBankSet) {
589
+ opts.openingBank = priorClose.ending.bank;
590
+ } else if (opts.openingBank !== priorClose.ending.bank) {
591
+ openingNotes.push(
592
+ `note: --opening-bank ${opts.openingBank} overrides the prior close's ` +
593
+ `ending bank balance ${priorClose.ending.bank}; the roll-forward ` +
594
+ "continuity check below will flag the resulting gap"
595
+ );
596
+ }
597
+ if (!opts.openingBookSet) {
598
+ opts.openingBook = priorClose.ending.book;
599
+ } else if (opts.openingBook !== priorClose.ending.book) {
600
+ openingNotes.push(
601
+ `note: --opening-book ${opts.openingBook} overrides the prior close's ` +
602
+ `ending book balance ${priorClose.ending.book}; the roll-forward ` +
603
+ "continuity check below will flag the resulting gap"
604
+ );
605
+ }
606
+ }
607
+ for (const n of openingNotes) writeErr(`${n}\n`);
608
+
609
+ // -- Read the three files (IO errors are exit 1, not a crash). -------------
610
+ let bankText;
611
+ let ledgerText;
612
+ let rentText;
613
+ try {
614
+ bankText = fs.readFileSync(path.resolve(opts.bank), "utf8");
615
+ } catch (e) {
616
+ writeErr(`error: cannot read bank file ${opts.bank}: ${e.message}\n`);
617
+ return { code: EXIT.IO };
618
+ }
619
+ try {
620
+ ledgerText = fs.readFileSync(path.resolve(opts.ledger), "utf8");
621
+ } catch (e) {
622
+ writeErr(`error: cannot read ledger file ${opts.ledger}: ${e.message}\n`);
623
+ return { code: EXIT.IO };
624
+ }
625
+ try {
626
+ rentText = fs.readFileSync(path.resolve(opts.rentroll), "utf8");
627
+ } catch (e) {
628
+ writeErr(`error: cannot read rent-roll file ${opts.rentroll}: ${e.message}\n`);
629
+ return { code: EXIT.IO };
630
+ }
631
+
632
+ // -- Resolve the per-source column maps (--map-file + --map). --------------
633
+ // A malformed --map-file (unreadable, bad JSON, unknown source key) is a USAGE
634
+ // error (a bad flag value, exit 2) — same class as a bad --policy file. The
635
+ // individual --map flags were already syntax-validated in the arg parser.
636
+ let sourceMaps;
637
+ try {
638
+ const mapFileMaps = opts.mapFile != null ? readMapFile(opts.mapFile) : null;
639
+ sourceMaps = buildSourceMaps(mapFileMaps, opts.mapArgs || []);
640
+ } catch (e) {
641
+ writeErr(`error: ${e.message}\n`);
642
+ return { code: EXIT.USAGE };
643
+ }
644
+
645
+ // -- Validate the resolved column maps up front (USAGE, not IO). -----------
646
+ // A STRUCTURALLY-INVALID column map (an unknown logical key, OR a mapped-to
647
+ // header absent from the file) is a BAD FLAG VALUE — the same error class as a
648
+ // malformed --map-file (readMapFile, exit 2) and as inspect's preview. Without
649
+ // this pre-flight the IDENTICAL mistake routed through an inline --map would
650
+ // fall through to the strict-ingest try/catch below and exit 1 (IO), splitting
651
+ // one broker mistake across exit 1/2/3 by flag form. We validate here, BEFORE
652
+ // any row parsing, reusing the SAME parseCSV + schema + validateColumnMap the
653
+ // strict parser uses, so a bad map exits 2 regardless of which flag carried it.
654
+ // The message (already naming the available headers/fields) is unchanged.
655
+ try {
656
+ ingest.validateColumnMapForSource(ingest.SOURCE.BANK, bankText, sourceMaps.bank, {
657
+ format: opts.bankFormat,
658
+ });
659
+ ingest.validateColumnMapForSource(
660
+ ingest.SOURCE.QUICKBOOKS,
661
+ ledgerText,
662
+ sourceMaps.ledger
663
+ );
664
+ ingest.validateColumnMapForSource(
665
+ ingest.SOURCE.RENT_ROLL,
666
+ rentText,
667
+ sourceMaps.rentroll
668
+ );
669
+ } catch (e) {
670
+ writeErr(`error: ${e.message}\n`);
671
+ return { code: EXIT.USAGE };
672
+ }
673
+
674
+ // -- Ingest (a malformed row is a clear, located error -> exit 1). ---------
675
+ // The resolved column maps thread into BOTH the bank/QB/rent parsers, so a
676
+ // file whose headers no alias matches still loads under an explicit map. A
677
+ // structurally-invalid map was already rejected (USAGE) by the pre-flight
678
+ // above; any IngestError reaching this catch is a genuine data/row problem.
679
+ let bank;
680
+ let book;
681
+ let rentroll;
682
+ try {
683
+ bank = ingest.parseBankStatement(bankText, {
684
+ format: opts.bankFormat,
685
+ columnMap: sourceMaps.bank,
686
+ });
687
+ book = ingest.parseQuickBooksCSV(ledgerText, { columnMap: sourceMaps.ledger });
688
+ rentroll = ingest.parseRentRollCSV(rentText, {
689
+ columnMap: sourceMaps.rentroll,
690
+ });
691
+ } catch (e) {
692
+ writeErr(`error: ${e.message}\n`);
693
+ return { code: EXIT.IO };
694
+ }
695
+
696
+ // -- Build the packet model (match + reconcile inside). --------------------
697
+ let model;
698
+ try {
699
+ model = report.buildPacket({
700
+ bank,
701
+ book,
702
+ rentroll,
703
+ reportDate,
704
+ period: opts.period,
705
+ opening: { bank: opts.openingBank || 0, book: opts.openingBook || 0 },
706
+ toleranceCents: opts.toleranceCents || 0,
707
+ policy: activePolicy,
708
+ priorClose,
709
+ emitClosePath: opts.emitClose != null ? path.resolve(opts.emitClose) : null,
710
+ });
711
+ } catch (e) {
712
+ writeErr(`error: ${e.message}\n`);
713
+ return { code: EXIT.IO };
714
+ }
715
+
716
+ const summary = report.summaryLine(model);
717
+ // T-43.2: the ROOT-CAUSE triage headline, printed as a SECOND human line AFTER
718
+ // the verdict summary (which stays byte-for-byte the existing first line). It
719
+ // names the make-or-break distinction at first contact — a genuine OUT-OF-TRUST
720
+ // finding vs. a data-shape gap to fix and re-run vs. nothing to fix. It is
721
+ // PURELY additive: it never changes the PASS/FAIL verdict or the exit code.
722
+ const triageLine = report.triageHeadline(model);
723
+ const render = report.renderPacket(model);
724
+ const code = model.pass ? EXIT.PASS : EXIT.FAIL;
725
+
726
+ // -- Emit THIS run's period close (--emit-close), if requested. ------------
727
+ // Built PURELY from the packet model (close.buildClose) and written ONLY to the
728
+ // caller-named path — never silently to cwd, exactly like the packet. The close
729
+ // round-trips through close.readClose so the next month's --prior-close consumes
730
+ // it. This run's verdict/exit code is unaffected by emitting it.
731
+ let closeWritten = null;
732
+ if (opts.emitClose != null) {
733
+ const closePath = path.resolve(opts.emitClose);
734
+ let closeArtifact;
735
+ try {
736
+ closeArtifact = close.buildClose(model);
737
+ } catch (e) {
738
+ writeErr(`error: cannot build --emit-close artifact: ${e.message}\n`);
739
+ return { code: EXIT.IO };
740
+ }
741
+ try {
742
+ const parent = path.dirname(closePath);
743
+ fs.mkdirSync(parent, { recursive: true });
744
+ fs.writeFileSync(closePath, JSON.stringify(closeArtifact, null, 2) + "\n");
745
+ } catch (e) {
746
+ writeErr(
747
+ `error: cannot write --emit-close file ${opts.emitClose}: ${e.message}\n`
748
+ );
749
+ return { code: EXIT.IO };
750
+ }
751
+ closeWritten = closePath;
752
+ }
753
+
754
+ // -- Output. ---------------------------------------------------------------
755
+ let written = [];
756
+ let sealWritten = null;
757
+ if (opts.out) {
758
+ // Write the packet ONLY into the caller-chosen directory. Create it if
759
+ // missing (recursively), but never write outside it and never to cwd.
760
+ const outDir = path.resolve(opts.out);
761
+ try {
762
+ fs.mkdirSync(outDir, { recursive: true });
763
+ } catch (e) {
764
+ writeErr(`error: cannot create --out directory ${opts.out}: ${e.message}\n`);
765
+ return { code: EXIT.IO };
766
+ }
767
+ try {
768
+ for (const name of Object.keys(render).sort()) {
769
+ const p = path.join(outDir, name);
770
+ fs.writeFileSync(p, render[name]);
771
+ written.push(p);
772
+ }
773
+ } catch (e) {
774
+ writeErr(`error: cannot write packet into ${opts.out}: ${e.message}\n`);
775
+ return { code: EXIT.IO };
776
+ }
777
+
778
+ // -- Emit the reconciliation seal (--seal), AFTER every packet file (and the
779
+ // emitted close, if any) is on disk. The CLI does ALL the file READING here
780
+ // and hands seal.js already-loaded { relPath, bytes } entries — seal.js stays
781
+ // PURE. The seal binds the 3 SOURCE inputs (by their logical role) + every
782
+ // emitted packet file (+ the emitted close, if --emit-close) into ONE
783
+ // content-addressed root.
784
+ //
785
+ // PORTABLE relPaths (REWORK Finding 1). The deliverable is "the packet a broker
786
+ // hands a state examiner months later." For that handoff to verify, the sealed
787
+ // relPaths must NOT depend on the producing machine's on-disk layout:
788
+ // * OUTPUTS live in the out dir, so they are sealed by their BASENAME
789
+ // (path.relative(sealDir, abs) is already the basename when the seal sits in
790
+ // the same dir — the common case; a caller-named seal elsewhere still resolves).
791
+ // * INPUTS are the 3 ORIGINAL sources, which may live ANYWHERE (a month folder,
792
+ // a sibling tree, an absolute path). Sealing them by a seal-dir-relative path
793
+ // would (a) escape the packet dir as `../bank.csv` so shipping ONLY the out/
794
+ // folder reports them MISSING, and (b) leak the producing machine's absolute
795
+ // home path when the sources live outside the out tree — and make the root
796
+ // depend on that layout, breaking "same inputs => same root." So inputs are
797
+ // sealed by their BASENAME: the broker ships each source NEXT TO the seal and
798
+ // `vh trust verify-seal` finds it with no machine-specific offset. (Inputs may
799
+ // be located elsewhere at verify time via `verify-seal --inputs <dir>`.)
800
+ if (opts.seal) {
801
+ const sealPath = opts.sealFile
802
+ ? path.resolve(opts.sealFile)
803
+ : path.join(outDir, `reconciliation-${reportDate}-seal.json`);
804
+ const sealDir = path.dirname(sealPath);
805
+
806
+ // The seal can never seal ITSELF (it does not exist yet, and its bytes depend
807
+ // on the very set being hashed). A caller-named seal path landing inside the
808
+ // packet dir is fine — it is simply not one of the sealed entries.
809
+ const relTo = (abs) => path.relative(sealDir, abs);
810
+
811
+ // Inputs: the 3 ORIGINAL sources, read from their original location, tagged
812
+ // with the seal's logical roles (bank / book / rentroll). ledger -> "book".
813
+ // Sealed by BASENAME so the binding travels with the packet (see header note).
814
+ const inputSpecs = [
815
+ { role: "bank", abs: path.resolve(opts.bank) },
816
+ { role: "book", abs: path.resolve(opts.ledger) },
817
+ { role: "rentroll", abs: path.resolve(opts.rentroll) },
818
+ ];
819
+ // Outputs: every emitted packet file, PLUS the emitted close if --emit-close
820
+ // was used (so the seal binds the WHOLE emitted artifact set).
821
+ const outputAbs = [...written];
822
+ if (closeWritten) outputAbs.push(closeWritten);
823
+
824
+ // Guard the basename-flattening: if two inputs (or an input and an output) would
825
+ // collide on the same name once flattened, the partition becomes ambiguous and
826
+ // seal.buildSeal would (correctly) reject a duplicate relPath. Surface it here as
827
+ // an actionable IO error naming the colliding name rather than a generic build error.
828
+ const inputRel = inputSpecs.map((s) => ({ ...s, relPath: path.basename(s.abs) }));
829
+ const outputRel = outputAbs.map((abs) => ({ abs, relPath: relTo(abs) }));
830
+ const seenName = new Map();
831
+ for (const r of [...inputRel, ...outputRel]) {
832
+ if (seenName.has(r.relPath)) {
833
+ writeErr(
834
+ `error: cannot build seal: two sealed files flatten to the same name ` +
835
+ `${JSON.stringify(r.relPath)} (rename a source so the bank/book/rentroll ` +
836
+ `inputs and the packet files each have a distinct filename)\n`
837
+ );
838
+ return { code: EXIT.IO };
839
+ }
840
+ seenName.set(r.relPath, true);
841
+ }
842
+
843
+ let files;
844
+ try {
845
+ files = {
846
+ inputs: inputRel.map((s) => ({
847
+ role: s.role,
848
+ relPath: s.relPath,
849
+ bytes: fs.readFileSync(s.abs),
850
+ })),
851
+ outputs: outputRel.map((o) => ({
852
+ relPath: o.relPath,
853
+ bytes: fs.readFileSync(o.abs),
854
+ })),
855
+ };
856
+ } catch (e) {
857
+ writeErr(`error: cannot read a file to seal: ${e.message}\n`);
858
+ return { code: EXIT.IO };
859
+ }
860
+
861
+ let sealObj;
862
+ try {
863
+ sealObj = seal.buildSeal({
864
+ files,
865
+ verdict: {
866
+ pass: model.pass,
867
+ reportDate: model.reportDate,
868
+ period: model.period == null ? null : model.period,
869
+ },
870
+ });
871
+ } catch (e) {
872
+ writeErr(`error: cannot build seal: ${e.message}\n`);
873
+ return { code: EXIT.IO };
874
+ }
875
+
876
+ try {
877
+ fs.mkdirSync(sealDir, { recursive: true });
878
+ fs.writeFileSync(sealPath, seal.serializeSeal(sealObj));
879
+ } catch (e) {
880
+ writeErr(`error: cannot write seal file ${sealPath}: ${e.message}\n`);
881
+ return { code: EXIT.IO };
882
+ }
883
+ sealWritten = sealPath;
884
+ }
885
+
886
+ if (opts.json) {
887
+ write(
888
+ JSON.stringify(
889
+ { ...model, summary, written, outDir, closeWritten, sealWritten },
890
+ null,
891
+ 2
892
+ ) + "\n"
893
+ );
894
+ } else {
895
+ write(`${summary}\n`);
896
+ write(`${triageLine}\n`);
897
+ for (const p of written) write(`wrote ${p}\n`);
898
+ if (closeWritten) write(`wrote close ${closeWritten}\n`);
899
+ if (sealWritten) write(`wrote seal ${sealWritten}\n`);
900
+ }
901
+ } else {
902
+ // No --out: print the summary + the HTML report to stdout, write NOTHING
903
+ // (except the explicitly caller-named --emit-close file, already written).
904
+ if (opts.json) {
905
+ write(JSON.stringify({ ...model, summary, closeWritten }, null, 2) + "\n");
906
+ } else {
907
+ write(`${summary}\n`);
908
+ write(`${triageLine}\n`);
909
+ if (closeWritten) write(`wrote close ${closeWritten}\n`);
910
+ const htmlName = report.packetFilenames(reportDate).html;
911
+ write("\n");
912
+ write(render[htmlName]);
913
+ }
914
+ }
915
+
916
+ return { code, model, summary, written, render, closeWritten, sealWritten };
917
+ }
918
+
919
+ // Real "today" as a UTC YYYY-MM-DD. The ONLY impure call in this module, isolated
920
+ // here and injectable so the pipeline itself stays deterministic.
921
+ function todayISO() {
922
+ const d = new Date();
923
+ const pad = (n) => String(n).padStart(2, "0");
924
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
925
+ }
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // `vh trust inspect` (T-25.2) — read-only file validator / preview
929
+ // ---------------------------------------------------------------------------
930
+ //
931
+ // The onboarding companion to `reconcile`. `reconcile` fails CLOSED (the first
932
+ // malformed row aborts the whole file) because a trust reconciliation must never
933
+ // silently partial-parse. That is correct for the gate, but it is a DEAD END
934
+ // when a broker first feeds the tool a real export: they get one error and no
935
+ // path forward. `inspect` turns that dead end into a self-service fix.
936
+ //
937
+ // It runs `diagnoseSource` over ONE file and prints, for that file: the detected
938
+ // header; the logical->header column map (or "(not found)"); the OK/total parse
939
+ // count; a small SAMPLE of normalized records; and EVERY failing row (number +
940
+ // reason). When a required column is missing OR any row failed it prints an
941
+ // ACTIONABLE hint and exits 3 (the data-gate FAIL code); a fully-clean file
942
+ // exits 0. It is STRICTLY read-only: it writes NOTHING anywhere — no packet, no
943
+ // receipt, not even with a path flag. It does NOT reconcile or attest; it only
944
+ // checks that the file PARSES into the normalized model.
945
+
946
+ // Map the broker-facing `--as` value to the ingest SOURCE. The three logical
947
+ // kinds a reconcile consumes: a bank statement, a QuickBooks ledger, a rent roll.
948
+ const INSPECT_AS = Object.freeze({
949
+ bank: ingest.SOURCE.BANK,
950
+ ledger: ingest.SOURCE.QUICKBOOKS,
951
+ rentroll: ingest.SOURCE.RENT_ROLL,
952
+ });
953
+
954
+ // Parse `inspect` argv: one positional <file>, plus flags. Unknown flags and a
955
+ // missing/duplicate positional are USAGE errors (parser parity with reconcile —
956
+ // a typo never silently returns a wrong view). `--as` is REQUIRED and validated.
957
+ function parseInspectArgs(argv) {
958
+ const opts = {
959
+ file: undefined,
960
+ as: undefined,
961
+ bankFormat: undefined,
962
+ json: false,
963
+ sample: undefined, // sample size (default applied by the runner)
964
+ mapArgs: [], // repeatable --map <logical>=<header> (source = --as) (T-25.3)
965
+ mapFile: undefined, // --map-file <json> per-source column maps (T-25.3)
966
+ _positionals: [],
967
+ };
968
+ for (let i = 0; i < argv.length; i++) {
969
+ const a = argv[i];
970
+ switch (a) {
971
+ case "--as":
972
+ opts.as = argv[++i];
973
+ if (opts.as === undefined) {
974
+ const e = new Error("--as requires a value");
975
+ e.usage = true;
976
+ throw e;
977
+ }
978
+ break;
979
+ case "--bank-format":
980
+ opts.bankFormat = argv[++i];
981
+ if (opts.bankFormat === undefined) {
982
+ const e = new Error("--bank-format requires a value");
983
+ e.usage = true;
984
+ throw e;
985
+ }
986
+ break;
987
+ case "--json":
988
+ opts.json = true;
989
+ break;
990
+ case "--sample":
991
+ opts.sample = parseIntArg(argv[++i], "--sample");
992
+ break;
993
+ case "--map":
994
+ // inspect: a single file, so the source is implied by --as; an optional
995
+ // <source>: prefix is still accepted (and must agree with --as later).
996
+ opts.mapArgs.push(parseMapArg(argv[++i], { requireSource: false }));
997
+ break;
998
+ case "--map-file":
999
+ opts.mapFile = argv[++i];
1000
+ break;
1001
+ default:
1002
+ if (a && a.startsWith("--")) {
1003
+ const e = new Error(`unknown option: ${a}`);
1004
+ e.usage = true;
1005
+ throw e;
1006
+ }
1007
+ opts._positionals.push(a);
1008
+ }
1009
+ }
1010
+ if (opts._positionals.length > 1) {
1011
+ const e = new Error(
1012
+ `unexpected extra argument: ${opts._positionals[1]} ` +
1013
+ "(inspect takes exactly one <file>)"
1014
+ );
1015
+ e.usage = true;
1016
+ throw e;
1017
+ }
1018
+ opts.file = opts._positionals[0];
1019
+ return opts;
1020
+ }
1021
+
1022
+ // Pretty-print signed integer cents as a signed dollar string (e.g. -75000 ->
1023
+ // "-750.00"). Pure; used only for the human SAMPLE table.
1024
+ function fmtCents(cents) {
1025
+ const n = Number(cents) || 0;
1026
+ const sign = n < 0 ? "-" : "";
1027
+ const abs = Math.abs(n);
1028
+ return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
1029
+ }
1030
+
1031
+ // The standing TrustLedger caveat the output LEADS with, and the inspect-specific
1032
+ // scope note. Centralized so the human and (commented) JSON paths agree.
1033
+ const INSPECT_CAVEAT =
1034
+ "TrustLedger AIDS reconciliation; the broker remains the responsible custodian.";
1035
+ const INSPECT_SCOPE =
1036
+ "`inspect` only checks that this file PARSES into the normalized model — it does " +
1037
+ "NOT reconcile or attest anything. To reconcile, run `vh trust reconcile`.";
1038
+
1039
+ // Render the diagnostic report as the human inspect view. Pure: takes the
1040
+ // report + resolved opts, returns a string. Leads with the caveat + scope, then
1041
+ // the header, the logical->header map, the OK/total count, the sample, every
1042
+ // failing row, and (when anything is wrong) the actionable hint.
1043
+ function renderInspect(report, opts) {
1044
+ const L = [];
1045
+ L.push(`# vh trust inspect — ${opts.as} (${opts.file})`);
1046
+ L.push(INSPECT_CAVEAT);
1047
+ L.push(INSPECT_SCOPE);
1048
+ L.push("");
1049
+
1050
+ // Detected format (CSV vs OFX/QFX) — honest about which path ran, so an OFX
1051
+ // bank export is recognized rather than mis-read as a one-column CSV.
1052
+ if (report.format) {
1053
+ L.push(`detected format: ${report.format}`);
1054
+ }
1055
+
1056
+ // Detected header columns (CSV header row, or the OFX tags we read).
1057
+ L.push(
1058
+ `${report.format === "ofx" ? "OFX tags" : "header columns"} ` +
1059
+ `(${report.header.length}): ` +
1060
+ (report.header.length ? report.header.join(", ") : "(none)")
1061
+ );
1062
+ L.push("");
1063
+
1064
+ // Logical field -> header it mapped to (or "(not found)").
1065
+ L.push("logical field -> header column:");
1066
+ for (const logical of Object.keys(report.mapped)) {
1067
+ const mapped = report.mapped[logical];
1068
+ const req = report.requiredMissing.includes(logical) ? " [REQUIRED]" : "";
1069
+ L.push(` ${logical}: ${mapped == null ? "(not found)" : mapped}${req}`);
1070
+ }
1071
+ L.push("");
1072
+
1073
+ // Parse count.
1074
+ L.push(`parsed: ${report.okCount} OK of ${report.rowCount} data row(s)`);
1075
+
1076
+ // Sample of normalized records (date / signed-cents / kind / party / memo).
1077
+ L.push("");
1078
+ if (report.sample.length) {
1079
+ L.push(`sample (first ${report.sample.length} normalized record(s)):`);
1080
+ for (const r of report.sample) {
1081
+ L.push(
1082
+ ` ${r.date} ${fmtCents(r.amount).padStart(12)} ${r.kind} ` +
1083
+ `${r.party || "(no party)"} | ${r.memo || ""}`.trimEnd()
1084
+ );
1085
+ }
1086
+ } else {
1087
+ L.push("sample: (no rows parsed)");
1088
+ }
1089
+
1090
+ // Every failing row with its number + reason.
1091
+ L.push("");
1092
+ if (report.errors.length) {
1093
+ L.push(`failures (${report.errors.length}):`);
1094
+ for (const e of report.errors) {
1095
+ const where = e.row == null ? "file" : `row ${e.row}`;
1096
+ L.push(` ${where}: ${e.message}`);
1097
+ }
1098
+ } else {
1099
+ L.push("failures: none");
1100
+ }
1101
+
1102
+ // Actionable hint when a required column is missing OR any row failed.
1103
+ const hint = inspectHint(report);
1104
+ if (hint.length) {
1105
+ L.push("");
1106
+ L.push("how to fix:");
1107
+ for (const h of hint) L.push(` - ${h}`);
1108
+ }
1109
+
1110
+ L.push("");
1111
+ return L.join("\n");
1112
+ }
1113
+
1114
+ // Build the actionable hint lines: for each missing required column, name the
1115
+ // accepted aliases the broker can rename/add a column to. A row-level failure
1116
+ // (with all required columns present) gets a generic "fix the cells" line.
1117
+ // Returns [] when the file is fully clean.
1118
+ //
1119
+ // T-25.3: the no-edit column-mapping override now EXISTS, so each missing-column
1120
+ // hint also names the WORKING `--map <logical>=<header>` escape hatch — a broker
1121
+ // whose header no alias matches can map it WITHOUT editing the export. The hint
1122
+ // only ever advertises what the tool can actually do today, and following it
1123
+ // (rename/add a column OR pass --map) succeeds — never a dead end.
1124
+ function inspectHint(report) {
1125
+ const out = [];
1126
+ for (const logical of report.requiredMissing) {
1127
+ const aliases = ingest.aliasesFor(report.source, logical);
1128
+ out.push(
1129
+ `the "${logical}" column was not found — rename your column to (or add) ` +
1130
+ `one named one of [${aliases.join(", ")}], OR map your existing header ` +
1131
+ `with --map ${logical}=<your header>`
1132
+ );
1133
+ }
1134
+ // The amount group (signed amount OR a split pair) is reported as a file-level
1135
+ // error rather than a missing single column; surface its own add-a-column hint.
1136
+ for (const e of report.errors) {
1137
+ if (e.row == null && /needs an "amount" column|debit\/credit|payment\/charge/.test(e.message)) {
1138
+ out.push(
1139
+ `${e.message} — rename/add one of those columns, OR map your existing ` +
1140
+ `header(s) with --map <logical>=<your header>`
1141
+ );
1142
+ }
1143
+ }
1144
+ // Row-level failures with the header otherwise intact: a per-row data problem.
1145
+ const rowFails = report.errors.filter((e) => e.row != null);
1146
+ if (rowFails.length) {
1147
+ out.push(
1148
+ `${rowFails.length} row(s) above failed to parse — fix the listed cells, ` +
1149
+ "then re-run `vh trust inspect` until 0 failures before `vh trust reconcile`"
1150
+ );
1151
+ }
1152
+ return out;
1153
+ }
1154
+
1155
+ // runInspect: read the one file, run diagnoseSource, render, and return
1156
+ // { code, report, render }. Read-only — writes NOTHING. Exit contract:
1157
+ // 0 = clean (every required column present AND every row parsed),
1158
+ // 3 = data-gate FAIL (a required/amount column missing OR any row failed),
1159
+ // 2 = usage error (bad --as), 1 = IO error (unreadable file) — consistent
1160
+ // with `reconcile`.
1161
+ function runInspect(opts, io = {}) {
1162
+ const write = io.write || ((s) => process.stdout.write(s));
1163
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1164
+
1165
+ if (!opts.file) {
1166
+ writeErr("error: `vh trust inspect` requires a <file>\n");
1167
+ return { code: EXIT.USAGE };
1168
+ }
1169
+ if (opts.as == null) {
1170
+ writeErr(
1171
+ "error: `vh trust inspect` requires --as <bank|ledger|rentroll>\n"
1172
+ );
1173
+ return { code: EXIT.USAGE };
1174
+ }
1175
+ const source = INSPECT_AS[opts.as];
1176
+ if (!source) {
1177
+ writeErr(
1178
+ `error: --as must be one of bank|ledger|rentroll (got "${opts.as}")\n`
1179
+ );
1180
+ return { code: EXIT.USAGE };
1181
+ }
1182
+ if (
1183
+ opts.bankFormat != null &&
1184
+ opts.bankFormat !== "csv" &&
1185
+ opts.bankFormat !== "ofx"
1186
+ ) {
1187
+ writeErr(
1188
+ `error: --bank-format must be "csv" or "ofx" (got "${opts.bankFormat}")\n`
1189
+ );
1190
+ return { code: EXIT.USAGE };
1191
+ }
1192
+
1193
+ // Resolve the column map for THIS file (the source is `--as`). --map-file may
1194
+ // carry per-source maps; only the entry for this --as applies. A bare --map
1195
+ // (no <source>: prefix) targets this --as; a prefixed --map MUST agree with it.
1196
+ // Malformed --map-file is a USAGE error, mirroring reconcile.
1197
+ let columnMap;
1198
+ try {
1199
+ const mapFileMaps = opts.mapFile != null ? readMapFile(opts.mapFile) : null;
1200
+ // Re-scope each --map onto this --as: a bare --map targets it; a prefixed
1201
+ // --map MUST agree with it (a plain loop, not a throwing filter callback).
1202
+ const scoped = [];
1203
+ for (const m of opts.mapArgs || []) {
1204
+ if (m.source !== undefined && m.source !== opts.as) {
1205
+ const e = new Error(
1206
+ `--map source "${m.source}" does not match --as ${opts.as}`
1207
+ );
1208
+ e.usage = true;
1209
+ throw e;
1210
+ }
1211
+ scoped.push({ ...m, source: opts.as });
1212
+ }
1213
+ const merged = buildSourceMaps(mapFileMaps, scoped);
1214
+ columnMap = merged[opts.as];
1215
+ } catch (e) {
1216
+ writeErr(`error: ${e.message}\n`);
1217
+ return { code: EXIT.USAGE };
1218
+ }
1219
+
1220
+ // Read the file (an unreadable file is exit 1, not a crash) — read-only.
1221
+ let text;
1222
+ try {
1223
+ text = fs.readFileSync(path.resolve(opts.file), "utf8");
1224
+ } catch (e) {
1225
+ writeErr(`error: cannot read file ${opts.file}: ${e.message}\n`);
1226
+ return { code: EXIT.IO };
1227
+ }
1228
+
1229
+ // Run the diagnostic core. It is PURE and side-effect-free.
1230
+ let report;
1231
+ try {
1232
+ report = ingest.diagnoseSource(source, text, {
1233
+ sampleSize: opts.sample == null ? 5 : opts.sample,
1234
+ // Honour --bank-format (csv|ofx) for --as bank; undefined => auto-detect.
1235
+ // Only meaningful for the bank source, ignored by diagnoseSource otherwise.
1236
+ format: opts.bankFormat,
1237
+ // The SAME map the reconcile run would use, so inspect previews identically.
1238
+ columnMap,
1239
+ });
1240
+ } catch (e) {
1241
+ // diagnoseSource only throws on an unknown source (already guarded above) or
1242
+ // a genuine (non-ingest) bug; treat as an input error rather than crashing.
1243
+ writeErr(`error: ${e.message}\n`);
1244
+ return { code: EXIT.IO };
1245
+ }
1246
+
1247
+ // Verdict: clean iff every required column is present AND every row parsed.
1248
+ const clean = report.requiredMissing.length === 0 && report.errors.length === 0;
1249
+ const code = clean ? EXIT.PASS : EXIT.FAIL;
1250
+
1251
+ if (opts.json) {
1252
+ write(
1253
+ JSON.stringify(
1254
+ {
1255
+ ...report,
1256
+ file: opts.file,
1257
+ as: opts.as,
1258
+ clean,
1259
+ code,
1260
+ hint: inspectHint(report),
1261
+ caveat: INSPECT_CAVEAT,
1262
+ scope: INSPECT_SCOPE,
1263
+ },
1264
+ null,
1265
+ 2
1266
+ ) + "\n"
1267
+ );
1268
+ } else {
1269
+ write(renderInspect(report, opts));
1270
+ }
1271
+
1272
+ return { code, report, render: undefined };
1273
+ }
1274
+
1275
+ function cmdInspect(argv, io = {}) {
1276
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1277
+ let opts;
1278
+ try {
1279
+ opts = parseInspectArgs(argv);
1280
+ } catch (e) {
1281
+ writeErr(`error: ${e.message}\n`);
1282
+ return EXIT.USAGE;
1283
+ }
1284
+ return runInspect(opts, io).code;
1285
+ }
1286
+
1287
+ // ---------------------------------------------------------------------------
1288
+ // `vh trust corpus [--json]` (T-44.2) — run the committed out-of-trust corpus
1289
+ // ---------------------------------------------------------------------------
1290
+ //
1291
+ // The one-command, read-only artifact a CPA or broker RUNS to confirm the gate
1292
+ // is correct WITHOUT reading test/. It loads the committed corpus, drives every
1293
+ // scenario through the REAL reconcile + buildPacket verdict path, and prints a
1294
+ // deterministic per-scenario table:
1295
+ //
1296
+ // id | control | principle (one sentence) | expected | actual | OK/MISMATCH
1297
+ //
1298
+ // plus a one-line summary. The exit code is the CI gate: 0 only when EVERY
1299
+ // scenario's live verdict matches its recorded verdict; 3 on ANY mismatch (a
1300
+ // corpus-drift / engine-regression signal — an out-of-trust case that did NOT
1301
+ // FAIL, or a benign case that did NOT PASS). It writes NOTHING. --json carries
1302
+ // the structured rows + summary so a pipeline can gate on the data, not the
1303
+ // rendered text. The exit contract reuses the family's: 0 ok, 3 gate FAIL, 2
1304
+ // usage (an unknown flag), 1 IO (a corpus that cannot be loaded).
1305
+
1306
+ const CORPUS_CAVEAT =
1307
+ "This is a CORRECTNESS-CORPUS check, not an audit. It proves the reconciliation " +
1308
+ "gate FAILs the canonical out-of-trust frauds and PASSes their benign twins, on " +
1309
+ "committed fixtures, through the SAME engine path the real reconcile exit uses.";
1310
+
1311
+ function parseCorpusArgs(argv) {
1312
+ const opts = { json: false };
1313
+ for (let i = 0; i < argv.length; i++) {
1314
+ const a = argv[i];
1315
+ if (a === "--json") {
1316
+ opts.json = true;
1317
+ } else {
1318
+ const e = new Error(
1319
+ `unknown flag for \`vh trust corpus\`: ${a} (expected: [--json])`
1320
+ );
1321
+ e.usage = true;
1322
+ throw e;
1323
+ }
1324
+ }
1325
+ return opts;
1326
+ }
1327
+
1328
+ // Pad a cell to a fixed width (left-justified) for a stable monospace table.
1329
+ function padCell(s, w) {
1330
+ const str = String(s == null ? "" : s);
1331
+ return str.length >= w ? str : str + " ".repeat(w - str.length);
1332
+ }
1333
+
1334
+ // Render the human table. Columns are fixed-width so the table is deterministic
1335
+ // and scannable; the principle is the one-sentence trust-law annotation and is
1336
+ // printed in full UNDER each row (not truncated) so the CPA/broker reads the
1337
+ // actual rule, while the aligned columns stay readable.
1338
+ function renderCorpus(result) {
1339
+ const lines = [];
1340
+ lines.push("vh trust corpus — out-of-trust correctness corpus");
1341
+ lines.push(CORPUS_CAVEAT);
1342
+ lines.push("");
1343
+
1344
+ const W_ID = 38;
1345
+ const W_CTRL = 28;
1346
+ const W_EXP = 8;
1347
+ const W_ACT = 8;
1348
+ const header =
1349
+ padCell("SCENARIO", W_ID) +
1350
+ " " +
1351
+ padCell("CONTROL", W_CTRL) +
1352
+ " " +
1353
+ padCell("EXPECT", W_EXP) +
1354
+ " " +
1355
+ padCell("ACTUAL", W_ACT) +
1356
+ " RESULT";
1357
+ lines.push(header);
1358
+ lines.push("-".repeat(header.length));
1359
+
1360
+ for (const r of result.rows) {
1361
+ lines.push(
1362
+ padCell(r.id, W_ID) +
1363
+ " " +
1364
+ padCell(r.control, W_CTRL) +
1365
+ " " +
1366
+ padCell(r.expected, W_EXP) +
1367
+ " " +
1368
+ padCell(r.actual, W_ACT) +
1369
+ " " +
1370
+ (r.match ? "OK" : "MISMATCH")
1371
+ );
1372
+ // The trust-law principle (one sentence), indented under its row.
1373
+ lines.push(" principle: " + r.principle);
1374
+ }
1375
+
1376
+ lines.push("");
1377
+ lines.push(corpusSummaryLine(result));
1378
+ return lines.join("\n") + "\n";
1379
+ }
1380
+
1381
+ // The one-line, machine-greppable summary. Leads with the verdict word
1382
+ // (CORPUS OK / CORPUS DRIFT) so a human and a `grep` agree on the gate state.
1383
+ function corpusSummaryLine(result) {
1384
+ if (result.ok) {
1385
+ return `CORPUS OK: ${result.matched}/${result.total} scenarios match their recorded verdict.`;
1386
+ }
1387
+ return (
1388
+ `CORPUS DRIFT: ${result.mismatched}/${result.total} scenario(s) did NOT match ` +
1389
+ `the recorded verdict (a regression in the gate or the corpus) — ` +
1390
+ `${result.matched}/${result.total} matched.`
1391
+ );
1392
+ }
1393
+
1394
+ function runCorpusCmd(opts, io = {}) {
1395
+ const write = io.write || ((s) => process.stdout.write(s));
1396
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1397
+
1398
+ // Load + run the whole corpus through the REAL engine path. A corpus that
1399
+ // cannot be loaded (missing/malformed fixtures) is an IO error, never a crash.
1400
+ // `opts.corpusDir` overrides the committed root (used by tests to drive a
1401
+ // deliberately-mislabeled copy and prove the gate is not a rubber stamp); the
1402
+ // shipped command always runs the committed corpus.
1403
+ let result;
1404
+ try {
1405
+ result =
1406
+ opts.corpusDir != null
1407
+ ? corpus.runCorpus(opts.corpusDir)
1408
+ : corpus.runCorpus();
1409
+ } catch (e) {
1410
+ writeErr(`error: cannot run trust corpus: ${e.message}\n`);
1411
+ return { code: EXIT.IO };
1412
+ }
1413
+
1414
+ // Exit code IS the gate: 0 only when every scenario matched; 3 on any drift.
1415
+ const code = result.ok ? EXIT.PASS : EXIT.FAIL;
1416
+
1417
+ if (opts.json) {
1418
+ write(
1419
+ JSON.stringify(
1420
+ {
1421
+ rows: result.rows,
1422
+ total: result.total,
1423
+ matched: result.matched,
1424
+ mismatched: result.mismatched,
1425
+ ok: result.ok,
1426
+ summary: corpusSummaryLine(result),
1427
+ caveat: CORPUS_CAVEAT,
1428
+ code,
1429
+ },
1430
+ null,
1431
+ 2
1432
+ ) + "\n"
1433
+ );
1434
+ } else {
1435
+ write(renderCorpus(result));
1436
+ }
1437
+
1438
+ return { code, result };
1439
+ }
1440
+
1441
+ function cmdCorpus(argv, io = {}) {
1442
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1443
+ let opts;
1444
+ try {
1445
+ opts = parseCorpusArgs(argv);
1446
+ } catch (e) {
1447
+ writeErr(`error: ${e.message}\n`);
1448
+ return EXIT.USAGE;
1449
+ }
1450
+ return runCorpusCmd(opts, io).code;
1451
+ }
1452
+
1453
+ // ---------------------------------------------------------------------------
1454
+ // `vh trust value-proof <bank> <ledger> <rentroll>` (T-45.2) — the read-only
1455
+ // "what your manual close let through" proof, as a CI-gateable command.
1456
+ // ---------------------------------------------------------------------------
1457
+ //
1458
+ // The go-to-market seam. A pilot broker's highest-signal input is a period they
1459
+ // ALREADY closed by hand and signed off as CLEAN. This command runs that SAME
1460
+ // already-closed period's three files through the EXACT reconcile/buildPacket
1461
+ // verdict path the gate uses, then diffs the gate's findings against the manual
1462
+ // close via the pure `valueproof.valueProof` lens, and prints — deterministically:
1463
+ //
1464
+ // * a one-word OUTCOME (clean_confirmed | out_of_trust_missed | data_gap_only),
1465
+ // * the one-sentence HEADLINE the broker reads to decide, and
1466
+ // * the per-class dollar table (the SAME numbers triage rolled up).
1467
+ //
1468
+ // Every number is read VERBATIM off the packet's own triage rollup — this command
1469
+ // adds NO new severity, finding, verdict, count, or money figure; it is a
1470
+ // presentation lens over the gate's existing output. A test drives the SAME inputs
1471
+ // through BOTH `reconcile` and `value-proof` and asserts the numbers are identical.
1472
+ //
1473
+ // EXIT CODE IS THE GATE (a CI pipeline can branch on it):
1474
+ // 0 = clean_confirmed (the gate AGREES the account is not out of trust),
1475
+ // 3 = out_of_trust_missed (a genuine shortage the manual close let through — FAIL),
1476
+ // 4 = data_gap_only (no out-of-trust finding, but the data could not fully
1477
+ // reconcile — fix-and-rerun, distinct from a real FAIL),
1478
+ // 2 = usage error (a bad flag), 1 = IO/input error (an unreadable/malformed file).
1479
+ //
1480
+ // FILESYSTEM HYGIENE: like `inspect` / `corpus`, it is STRICTLY read-only — it
1481
+ // writes NOTHING anywhere (no packet, no seal, not even with a path flag), so it
1482
+ // is safe to run on a partner's real files in any directory or CI step.
1483
+ //
1484
+ // THE MANUAL-CLOSE BASELINE: by default the broker is asserting the period was
1485
+ // CLEAN (the pilot case). `--asserted-flagged` flips the baseline to "the manual
1486
+ // close DID flag this period" (so `agrees` reflects a true positive). `--asserted-net
1487
+ // <cents>` echoes the manual close's signed-off net figure as an ANNOTATION ONLY —
1488
+ // it never changes the outcome, a verdict, a severity, a count, or any dollar number.
1489
+
1490
+ const VALUE_PROOF_CAVEAT =
1491
+ "This is a value-proof over the SAME reconcile gate, not a second opinion or an audit. " +
1492
+ "Every number is read verbatim off the period's reconciliation; it adds no new finding, " +
1493
+ "severity, or verdict. The broker remains the responsible custodian and a CPA review governs.";
1494
+
1495
+ // value-proof outcome -> exit code. The outcome strings are valueproof's closed
1496
+ // contract (VALUE_OUTCOME); a NULL-prototype map so a forged/unknown outcome can
1497
+ // never inherit a bogus code — it falls through to the explicit guard below.
1498
+ const VALUE_PROOF_EXIT = Object.freeze(
1499
+ Object.assign(Object.create(null), {
1500
+ [valueproof.VALUE_OUTCOME.CLEAN_CONFIRMED]: EXIT.PASS,
1501
+ [valueproof.VALUE_OUTCOME.OUT_OF_TRUST]: EXIT.FAIL,
1502
+ [valueproof.VALUE_OUTCOME.DATA_GAP]: EXIT.DATA_GAP,
1503
+ })
1504
+ );
1505
+
1506
+ // Parse `value-proof` argv. Three positional files (bank, ledger, rentroll) in
1507
+ // order, plus the SAME packet-shaping flags reconcile accepts (so the period is
1508
+ // reconciled IDENTICALLY) plus the manual-close baseline flags. Unknown flags and
1509
+ // missing positionals are USAGE errors, matching the rest of the family.
1510
+ //
1511
+ // VERDICT-EQUIVALENCE (T-45.2 rework). value-proof MUST run the broker's period
1512
+ // through the SAME verdict-shaping inputs the production reconcile gate threads,
1513
+ // or it is NOT verdict-equivalent to the gate the paying broker actually runs:
1514
+ // * --policy/--state — the licensed per-state policy that can ESCALATE an
1515
+ // exception's severity and FLIP the SAME files PASS->FAIL,
1516
+ // * --prior-close — the prior period's close that seeds opening balances
1517
+ // and can surface a CONTINUITY_BREAK,
1518
+ // * --license/--vendor — the license that unlocks the paid policy surface (the
1519
+ // SAME gate reconcile applies before honoring --policy),
1520
+ // * --map-file/--map — the per-source column maps so a broker whose real files
1521
+ // use non-default headers reconciles instead of IO-erroring.
1522
+ // Without these, value-proof could confidently print "clean confirmed" / exit 0 on
1523
+ // a closed period the broker's OWN licensed gate FAILs — the worst failure mode for
1524
+ // a counterparty-facing proof. They are threaded into buildPacket/ingest IDENTICALLY
1525
+ // to reconcile, so value-proof is the same verdict path, not a narrower one.
1526
+ function parseValueProofArgs(argv) {
1527
+ const opts = {
1528
+ bank: undefined,
1529
+ ledger: undefined,
1530
+ rentroll: undefined,
1531
+ json: false,
1532
+ date: undefined, // override the report date (default: today); MUST be YYYY-MM-DD
1533
+ period: undefined, // optional human label for the closed period
1534
+ openingBank: 0,
1535
+ openingBook: 0,
1536
+ toleranceCents: 0,
1537
+ bankFormat: undefined, // force "csv" | "ofx" for the bank file
1538
+ policyFile: undefined, // explicit per-state policy file (--policy <file>)
1539
+ state: undefined, // bundled per-state policy by its state code (--state <code>)
1540
+ priorClose: undefined, // prior period's close.json to roll forward FROM (--prior-close <file>)
1541
+ license: undefined, // path to a signed *.vhlicense.json unlocking the paid surface
1542
+ vendor: undefined, // 0x-address the license issuer is pinned to
1543
+ mapArgs: [], // repeatable --map <source>:<logical>=<header>
1544
+ mapFile: undefined, // --map-file <json> per-source column maps
1545
+ assertedClean: true, // the manual-close baseline; --asserted-flagged flips it
1546
+ assertedNetCents: undefined, // OPTIONAL echoed annotation (integer cents)
1547
+ _positionals: [],
1548
+ };
1549
+ for (let i = 0; i < argv.length; i++) {
1550
+ const a = argv[i];
1551
+ switch (a) {
1552
+ case "--json":
1553
+ opts.json = true;
1554
+ break;
1555
+ case "--date":
1556
+ opts.date = argv[++i];
1557
+ break;
1558
+ case "--period":
1559
+ opts.period = argv[++i];
1560
+ break;
1561
+ case "--opening-bank":
1562
+ opts.openingBank = parseCentsArg(argv[++i], "--opening-bank");
1563
+ opts.openingBankSet = true;
1564
+ break;
1565
+ case "--opening-book":
1566
+ opts.openingBook = parseCentsArg(argv[++i], "--opening-book");
1567
+ opts.openingBookSet = true;
1568
+ break;
1569
+ case "--tolerance-cents":
1570
+ opts.toleranceCents = parseIntArg(argv[++i], "--tolerance-cents");
1571
+ break;
1572
+ case "--bank-format":
1573
+ opts.bankFormat = argv[++i];
1574
+ break;
1575
+ case "--policy":
1576
+ opts.policyFile = argv[++i];
1577
+ break;
1578
+ case "--state":
1579
+ opts.state = argv[++i];
1580
+ break;
1581
+ case "--prior-close":
1582
+ opts.priorClose = argv[++i];
1583
+ break;
1584
+ case "--license":
1585
+ opts.license = argv[++i];
1586
+ break;
1587
+ case "--vendor":
1588
+ opts.vendor = argv[++i];
1589
+ break;
1590
+ case "--map":
1591
+ // value-proof has three files (like reconcile), so the source is REQUIRED.
1592
+ opts.mapArgs.push(parseMapArg(argv[++i], { requireSource: true }));
1593
+ break;
1594
+ case "--map-file":
1595
+ opts.mapFile = argv[++i];
1596
+ break;
1597
+ case "--asserted-clean":
1598
+ // Explicit form of the default baseline (the period was closed clean).
1599
+ opts.assertedClean = true;
1600
+ break;
1601
+ case "--asserted-flagged":
1602
+ // The manual close DID flag this period (so `agrees` is a true positive).
1603
+ opts.assertedClean = false;
1604
+ break;
1605
+ case "--asserted-net":
1606
+ // A DOLLAR figure (parsed to integer cents, exactly like --opening-bank),
1607
+ // echoed as an annotation only — it NEVER changes the outcome or any number.
1608
+ opts.assertedNetCents = parseCentsArg(argv[++i], "--asserted-net");
1609
+ break;
1610
+ default:
1611
+ if (a && a.startsWith("--")) {
1612
+ const e = new Error(`unknown option: ${a}`);
1613
+ e.usage = true;
1614
+ throw e;
1615
+ }
1616
+ opts._positionals.push(a);
1617
+ }
1618
+ }
1619
+ if (opts._positionals.length > 3) {
1620
+ const e = new Error(
1621
+ `unexpected extra argument: ${opts._positionals[3]} ` +
1622
+ "(value-proof takes exactly three files: <bank> <ledger> <rentroll>)"
1623
+ );
1624
+ e.usage = true;
1625
+ throw e;
1626
+ }
1627
+ [opts.bank, opts.ledger, opts.rentroll] = opts._positionals;
1628
+ return opts;
1629
+ }
1630
+
1631
+ // Render the human value-proof report. PURE: takes the valueProof result + ctx,
1632
+ // returns a string. Leads with the caveat, then the outcome word + the production
1633
+ // gate's PASS/FAIL verdict, the headline, the per-class dollar table (the SAME
1634
+ // numbers triage rolled up), the totals + the manual-close annotation. Mirrors the
1635
+ // inspect/corpus output shape. `ctx.gateVerdict` is the gate's actual verdict under
1636
+ // the governing policy/prior-close; `ctx.escalated`/`ctx.tolerated` disclose when the
1637
+ // policy/prior-close flipped the verdict relative to the type-based class outcome.
1638
+ function renderValueProof(vp, ctx) {
1639
+ const L = [];
1640
+ L.push(`# vh trust value-proof — ${ctx.period == null ? "(period unlabeled)" : ctx.period}`);
1641
+ L.push(VALUE_PROOF_CAVEAT);
1642
+ L.push("");
1643
+ L.push(`outcome: ${vp.outcome}`);
1644
+ if (ctx.gateVerdict != null) {
1645
+ L.push(`gate verdict: ${ctx.gateVerdict} (the production reconcile gate's verdict for these inputs)`);
1646
+ }
1647
+ L.push(`headline: ${vp.headline}`);
1648
+ // Disclose a policy/prior-close-driven divergence between the type-based class
1649
+ // outcome and the gate's actual verdict, so the human is never shown a "clean"
1650
+ // class outcome on a period the licensed gate FAILs (or vice-versa) without a word.
1651
+ if (ctx.escalated) {
1652
+ L.push(
1653
+ "note: the governing policy/prior-close ESCALATED a finding the type-based class " +
1654
+ "outcome treats as benign — the production gate FAILs this period, so this is NOT " +
1655
+ "a clean confirmation. Re-run `vh trust reconcile` with the SAME policy for the full packet."
1656
+ );
1657
+ } else if (ctx.tolerated) {
1658
+ L.push(
1659
+ "note: the governing policy DOWN-graded an out-of-trust-class finding (named in the table " +
1660
+ "below); the production gate PASSes this period under that policy, so this is not failed as " +
1661
+ "out-of-trust here. A CPA should confirm the policy's grading is correct."
1662
+ );
1663
+ }
1664
+ L.push("");
1665
+
1666
+ // Per-class dollar table (already rank-sorted most-urgent-first by triage).
1667
+ const W_CLASS = 18;
1668
+ const W_COUNT = 7;
1669
+ const header =
1670
+ padCell("CLASS", W_CLASS) + " " + padCell("COUNT", W_COUNT) + " IMPACT";
1671
+ L.push("findings the manual close did not flag, by root cause:");
1672
+ L.push(header);
1673
+ L.push("-".repeat(header.length));
1674
+ if (vp.missedFindings.byClass.length === 0) {
1675
+ L.push(" (none — the gate found nothing the manual close missed)");
1676
+ } else {
1677
+ for (const c of vp.missedFindings.byClass) {
1678
+ L.push(
1679
+ padCell(c.label, W_CLASS) +
1680
+ " " +
1681
+ padCell(String(c.count), W_COUNT) +
1682
+ " " +
1683
+ fmtDollars(c.absImpact)
1684
+ );
1685
+ }
1686
+ }
1687
+ L.push(
1688
+ "-".repeat(header.length) +
1689
+ "\n" +
1690
+ padCell("TOTAL", W_CLASS) +
1691
+ " " +
1692
+ padCell(String(vp.missedFindings.count), W_COUNT) +
1693
+ " " +
1694
+ fmtDollars(vp.missedFindings.absImpact)
1695
+ );
1696
+ L.push("");
1697
+
1698
+ // The manual-close baseline + the agreement flag + the optional net annotation.
1699
+ L.push(
1700
+ `manual close asserted: ${vp.manualCloseClean ? "CLEAN" : "FLAGGED"}` +
1701
+ ` (gate ${vp.agrees ? "AGREES" : "DISAGREES"})`
1702
+ );
1703
+ if (vp.assertedNetCents != null) {
1704
+ L.push(`manual close net (annotation only): ${fmtDollars(vp.assertedNetCents)}`);
1705
+ }
1706
+ L.push("");
1707
+ return L.join("\n");
1708
+ }
1709
+
1710
+ // Format integer cents as a signed dollar string with thousands grouping
1711
+ // ("$1,234.56" / "-$1,234.56"), for the human table. Mirrors valueproof's own
1712
+ // fmtCents grouping locally so the CLI takes no new dependency on it. PURE.
1713
+ function fmtDollars(cents) {
1714
+ const n = Number(cents) || 0;
1715
+ const neg = n < 0;
1716
+ const abs = Math.abs(n);
1717
+ const dollars = Math.floor(abs / 100);
1718
+ const rem = abs % 100;
1719
+ const grouped = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1720
+ const body = `$${grouped}.${String(rem).padStart(2, "0")}`;
1721
+ return neg ? `-${body}` : body;
1722
+ }
1723
+
1724
+ // runValueProof reads the three files, runs the SAME ingest + buildPacket verdict
1725
+ // path reconcile uses (license gate, --policy/--state, --prior-close, --map-file/--map
1726
+ // threaded IDENTICALLY), applies the pure valueProof lens, prints the outcome +
1727
+ // headline + per-class dollar table, and returns { code, model, vp }. Read-only —
1728
+ // writes NOTHING. The exit code maps the outcome through VALUE_PROOF_EXIT and is then
1729
+ // RECONCILED with the gate's actual PASS/FAIL so value-proof never claims "clean
1730
+ // confirmed" (exit 0) on a period the broker's OWN licensed gate FAILs.
1731
+ function runValueProof(opts, io = {}) {
1732
+ const write = io.write || ((s) => process.stdout.write(s));
1733
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1734
+
1735
+ if (!opts.bank || !opts.ledger || !opts.rentroll) {
1736
+ writeErr(
1737
+ "error: `vh trust value-proof` requires three files: <bank> <ledger> <rentroll>\n"
1738
+ );
1739
+ return { code: EXIT.USAGE };
1740
+ }
1741
+
1742
+ // Report date: explicit --date wins (keeps output reproducible); else today.
1743
+ let reportDate = opts.date;
1744
+ if (reportDate == null) {
1745
+ reportDate = (io.today || todayISO)();
1746
+ }
1747
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(reportDate || ""))) {
1748
+ writeErr(`error: --date must be "YYYY-MM-DD" (got "${reportDate}")\n`);
1749
+ return { code: EXIT.USAGE };
1750
+ }
1751
+
1752
+ if (
1753
+ opts.bankFormat != null &&
1754
+ opts.bankFormat !== "csv" &&
1755
+ opts.bankFormat !== "ofx"
1756
+ ) {
1757
+ writeErr(
1758
+ `error: --bank-format must be "csv" or "ofx" (got "${opts.bankFormat}")\n`
1759
+ );
1760
+ return { code: EXIT.USAGE };
1761
+ }
1762
+
1763
+ // -- LICENSE GATE. IDENTICAL to reconcile (gateReconcile) — the per-state policy
1764
+ // (--state/--policy) is the SAME license-gated PAID surface here. value-proof
1765
+ // must REFUSE a policy run without a valid, vendor-pinned license exactly as
1766
+ // reconcile does; otherwise it would silently run an UNLICENSED policy path that
1767
+ // the production gate would never grant. The free baseline (no --state/--policy)
1768
+ // needs no license and is byte-for-byte unchanged.
1769
+ const gate = gateReconcile(opts, reportDate, writeErr);
1770
+ if (gate.code !== EXIT.PASS) return { code: gate.code };
1771
+
1772
+ // -- Resolve the per-state policy (--policy/--state), IDENTICALLY to reconcile.
1773
+ // Mutually-exclusive flags, an unknown --state, and a malformed/unreadable
1774
+ // policy file are USAGE errors (a bad flag value), matching reconcile exactly.
1775
+ // A policy can ESCALATE a finding's severity and FLIP the gate PASS->FAIL on the
1776
+ // SAME files — which is precisely why value-proof must thread it.
1777
+ let activePolicy = null;
1778
+ if (opts.policyFile != null && opts.state != null) {
1779
+ writeErr(
1780
+ "error: --policy and --state are mutually exclusive (choose an explicit " +
1781
+ "policy file OR a bundled state code, not both)\n"
1782
+ );
1783
+ return { code: EXIT.USAGE };
1784
+ }
1785
+ if (opts.state != null) {
1786
+ try {
1787
+ activePolicy = policy.resolveState(opts.state);
1788
+ } catch (e) {
1789
+ writeErr(`error: ${e.message}\n`);
1790
+ return { code: EXIT.USAGE };
1791
+ }
1792
+ } else if (opts.policyFile != null) {
1793
+ let policyText;
1794
+ try {
1795
+ policyText = fs.readFileSync(path.resolve(opts.policyFile), "utf8");
1796
+ } catch (e) {
1797
+ writeErr(`error: cannot read --policy file ${opts.policyFile}: ${e.message}\n`);
1798
+ return { code: EXIT.USAGE };
1799
+ }
1800
+ try {
1801
+ activePolicy = policy.readPolicy(policyText);
1802
+ } catch (e) {
1803
+ writeErr(`error: invalid --policy file ${opts.policyFile}: ${e.message}\n`);
1804
+ return { code: EXIT.USAGE };
1805
+ }
1806
+ }
1807
+
1808
+ // -- Resolve the prior period's close (--prior-close), IDENTICALLY to reconcile.
1809
+ // SEED-then-OVERRIDE: the prior close's ending seeds this run's opening unless
1810
+ // an explicit --opening-bank/--opening-book overrode it (a disagreeing override
1811
+ // is honored but surfaces as a CONTINUITY_BREAK in the packet, flipping the
1812
+ // verdict — never silently swallowed). A malformed/unreadable close is a USAGE
1813
+ // error (a bad flag value), exactly as in reconcile.
1814
+ let priorClose = null;
1815
+ const openingNotes = [];
1816
+ if (opts.priorClose != null) {
1817
+ let closeText;
1818
+ try {
1819
+ closeText = fs.readFileSync(path.resolve(opts.priorClose), "utf8");
1820
+ } catch (e) {
1821
+ writeErr(
1822
+ `error: cannot read --prior-close file ${opts.priorClose}: ${e.message}\n`
1823
+ );
1824
+ return { code: EXIT.USAGE };
1825
+ }
1826
+ try {
1827
+ priorClose = close.readClose(closeText);
1828
+ } catch (e) {
1829
+ writeErr(
1830
+ `error: invalid --prior-close file ${opts.priorClose}: ${e.message}\n`
1831
+ );
1832
+ return { code: EXIT.USAGE };
1833
+ }
1834
+ if (!opts.openingBankSet) {
1835
+ opts.openingBank = priorClose.ending.bank;
1836
+ } else if (opts.openingBank !== priorClose.ending.bank) {
1837
+ openingNotes.push(
1838
+ `note: --opening-bank ${opts.openingBank} overrides the prior close's ` +
1839
+ `ending bank balance ${priorClose.ending.bank}; the roll-forward ` +
1840
+ "continuity check below will flag the resulting gap"
1841
+ );
1842
+ }
1843
+ if (!opts.openingBookSet) {
1844
+ opts.openingBook = priorClose.ending.book;
1845
+ } else if (opts.openingBook !== priorClose.ending.book) {
1846
+ openingNotes.push(
1847
+ `note: --opening-book ${opts.openingBook} overrides the prior close's ` +
1848
+ `ending book balance ${priorClose.ending.book}; the roll-forward ` +
1849
+ "continuity check below will flag the resulting gap"
1850
+ );
1851
+ }
1852
+ }
1853
+ for (const n of openingNotes) writeErr(`${n}\n`);
1854
+
1855
+ // -- Read the three files (IO errors are exit 1, not a crash). -------------
1856
+ // Same error classes + messages as reconcile, so the SAME bad file fails the
1857
+ // SAME way regardless of which command read it.
1858
+ let bankText;
1859
+ let ledgerText;
1860
+ let rentText;
1861
+ try {
1862
+ bankText = fs.readFileSync(path.resolve(opts.bank), "utf8");
1863
+ } catch (e) {
1864
+ writeErr(`error: cannot read bank file ${opts.bank}: ${e.message}\n`);
1865
+ return { code: EXIT.IO };
1866
+ }
1867
+ try {
1868
+ ledgerText = fs.readFileSync(path.resolve(opts.ledger), "utf8");
1869
+ } catch (e) {
1870
+ writeErr(`error: cannot read ledger file ${opts.ledger}: ${e.message}\n`);
1871
+ return { code: EXIT.IO };
1872
+ }
1873
+ try {
1874
+ rentText = fs.readFileSync(path.resolve(opts.rentroll), "utf8");
1875
+ } catch (e) {
1876
+ writeErr(`error: cannot read rent-roll file ${opts.rentroll}: ${e.message}\n`);
1877
+ return { code: EXIT.IO };
1878
+ }
1879
+
1880
+ // -- Resolve the per-source column maps (--map-file + --map), IDENTICALLY to
1881
+ // reconcile, and validate them up front (a structurally-invalid map is a USAGE
1882
+ // error, the SAME class as a malformed --map-file). Without this a broker whose
1883
+ // real files use non-default headers would IO-error here where reconcile loads.
1884
+ let sourceMaps;
1885
+ try {
1886
+ const mapFileMaps = opts.mapFile != null ? readMapFile(opts.mapFile) : null;
1887
+ sourceMaps = buildSourceMaps(mapFileMaps, opts.mapArgs || []);
1888
+ } catch (e) {
1889
+ writeErr(`error: ${e.message}\n`);
1890
+ return { code: EXIT.USAGE };
1891
+ }
1892
+ try {
1893
+ ingest.validateColumnMapForSource(ingest.SOURCE.BANK, bankText, sourceMaps.bank, {
1894
+ format: opts.bankFormat,
1895
+ });
1896
+ ingest.validateColumnMapForSource(
1897
+ ingest.SOURCE.QUICKBOOKS,
1898
+ ledgerText,
1899
+ sourceMaps.ledger
1900
+ );
1901
+ ingest.validateColumnMapForSource(
1902
+ ingest.SOURCE.RENT_ROLL,
1903
+ rentText,
1904
+ sourceMaps.rentroll
1905
+ );
1906
+ } catch (e) {
1907
+ writeErr(`error: ${e.message}\n`);
1908
+ return { code: EXIT.USAGE };
1909
+ }
1910
+
1911
+ // -- Ingest (a malformed row is a clear, located error -> exit 1). The resolved
1912
+ // column maps thread into the parsers IDENTICALLY to reconcile.
1913
+ let bank;
1914
+ let book;
1915
+ let rentroll;
1916
+ try {
1917
+ bank = ingest.parseBankStatement(bankText, {
1918
+ format: opts.bankFormat,
1919
+ columnMap: sourceMaps.bank,
1920
+ });
1921
+ book = ingest.parseQuickBooksCSV(ledgerText, { columnMap: sourceMaps.ledger });
1922
+ rentroll = ingest.parseRentRollCSV(rentText, {
1923
+ columnMap: sourceMaps.rentroll,
1924
+ });
1925
+ } catch (e) {
1926
+ writeErr(`error: ${e.message}\n`);
1927
+ return { code: EXIT.IO };
1928
+ }
1929
+
1930
+ // -- Build the SAME packet model reconcile builds (match + reconcile + triage
1931
+ // inside), threading policy + priorClose IDENTICALLY to reconcile (cli.js:699).
1932
+ // value-proof reuses the EXACT verdict path so its numbers — AND its PASS/FAIL
1933
+ // verdict — EQUAL the reconcile verdict for the same inputs. value-proof writes
1934
+ // NOTHING, so it never threads emitClosePath (the close-emit is reconcile-only).
1935
+ let model;
1936
+ try {
1937
+ model = report.buildPacket({
1938
+ bank,
1939
+ book,
1940
+ rentroll,
1941
+ reportDate,
1942
+ period: opts.period,
1943
+ opening: { bank: opts.openingBank || 0, book: opts.openingBook || 0 },
1944
+ toleranceCents: opts.toleranceCents || 0,
1945
+ policy: activePolicy,
1946
+ priorClose,
1947
+ });
1948
+ } catch (e) {
1949
+ writeErr(`error: ${e.message}\n`);
1950
+ return { code: EXIT.IO };
1951
+ }
1952
+
1953
+ // -- Apply the pure value-proof lens over the gate's already-computed triage.
1954
+ // A ValueProofError here is an internal/forged-model bug, not a broker
1955
+ // mistake; surface it as an IO/input error rather than crashing.
1956
+ let vp;
1957
+ try {
1958
+ vp = valueproof.valueProof(model, {
1959
+ assertedClean: opts.assertedClean,
1960
+ assertedNetCents:
1961
+ opts.assertedNetCents != null ? opts.assertedNetCents : undefined,
1962
+ period: opts.period != null ? opts.period : reportDate,
1963
+ });
1964
+ } catch (e) {
1965
+ writeErr(`error: cannot build value-proof: ${e.message}\n`);
1966
+ return { code: EXIT.IO };
1967
+ }
1968
+
1969
+ // -- Map the outcome to the gateable exit code, then RECONCILE it with the gate's
1970
+ // actual PASS/FAIL verdict. -------------------------------------------------
1971
+ //
1972
+ // The valueProof OUTCOME keys off the root-cause CLASS (type-based, policy-
1973
+ // INDEPENDENT), so the per-class dollar rollup is faithful no matter the policy.
1974
+ // But a per-state policy (or a prior-close CONTINUITY_BREAK) can ESCALATE a finding
1975
+ // the class lens treats as benign (e.g. an `ambiguous_deposit` graded WARNING->ERROR,
1976
+ // or a `needs_review` note made a hard finding) and FLIP the production gate
1977
+ // PASS->FAIL on the SAME files — WITHOUT changing topClass. If we exited purely on
1978
+ // the class outcome, value-proof could print "clean confirmed" / exit 0 on a period
1979
+ // the broker's OWN licensed gate FAILs: the exact inversion this command must never
1980
+ // make. So when the gate FAILs (model.pass === false) we NEVER report clean/exit 0:
1981
+ // a class-clean outcome under a failing gate is surfaced as a policy-escalated FAIL.
1982
+ // (model.pass FAILs => exit is 3 or 4, always non-zero; PASSes => 0 or 4, never 3.)
1983
+ const baseCode = VALUE_PROOF_EXIT[vp.outcome];
1984
+ if (baseCode === undefined) {
1985
+ writeErr(`error: value-proof produced an unknown outcome "${vp.outcome}"\n`);
1986
+ return { code: EXIT.IO };
1987
+ }
1988
+ const gatePass = model.pass === true;
1989
+ // The gate verdict ANCHORS the exit code, refined by the class diagnosis:
1990
+ // * gate FAILs + class clean -> policy/prior-close escalated a hard finding
1991
+ // the type lens calls benign: FAIL (3), never 0.
1992
+ // * gate PASSes + class out-of-trust -> the policy DOWN-graded an out-of-trust
1993
+ // finding so the licensed gate tolerates it:
1994
+ // the gate PASSes, so we do not FAIL it (->
1995
+ // data_gap/4 when there is also a data gap,
1996
+ // else clean/0). The faithful per-class table
1997
+ // still names the finding; the verdict matches
1998
+ // the gate the broker actually runs.
1999
+ // Otherwise the class outcome already agrees with the gate and rides through.
2000
+ let code = baseCode;
2001
+ let escalated = false; // gate FAILs but the type-based outcome was clean
2002
+ let tolerated = false; // gate PASSes but the type-based outcome was out-of-trust
2003
+ if (!gatePass && code === EXIT.PASS) {
2004
+ code = EXIT.FAIL;
2005
+ escalated = true;
2006
+ } else if (gatePass && code === EXIT.FAIL) {
2007
+ code = vp.dataGap ? EXIT.DATA_GAP : EXIT.PASS;
2008
+ tolerated = true;
2009
+ }
2010
+
2011
+ // The verdict surfaced to the human + JSON is the GATE's verdict, so the
2012
+ // clean/out-of-trust framing can never disagree with the exit code.
2013
+ const gateVerdict = gatePass ? "PASS" : "FAIL";
2014
+
2015
+ if (opts.json) {
2016
+ write(
2017
+ JSON.stringify(
2018
+ {
2019
+ ...vp,
2020
+ reportDate,
2021
+ // The production gate's actual verdict for these inputs under the governing
2022
+ // policy/prior-close — what the paying broker's licensed gate would return.
2023
+ gateVerdict,
2024
+ // True iff the policy/prior-close FLIPPED the verdict relative to the
2025
+ // type-based class outcome (escalated a benign-class finding to a FAIL, or
2026
+ // tolerated an out-of-trust-class finding the gate PASSes). Disclosed so a
2027
+ // consumer can see the class outcome and the gate verdict diverged and why.
2028
+ policyEscalated: escalated,
2029
+ policyTolerated: tolerated,
2030
+ code,
2031
+ caveat: VALUE_PROOF_CAVEAT,
2032
+ },
2033
+ null,
2034
+ 2
2035
+ ) + "\n"
2036
+ );
2037
+ } else {
2038
+ write(
2039
+ renderValueProof(vp, {
2040
+ period: opts.period,
2041
+ gateVerdict,
2042
+ escalated,
2043
+ tolerated,
2044
+ })
2045
+ );
2046
+ }
2047
+
2048
+ return { code, model, vp };
2049
+ }
2050
+
2051
+ function cmdValueProof(argv, io = {}) {
2052
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2053
+ let opts;
2054
+ try {
2055
+ opts = parseValueProofArgs(argv);
2056
+ } catch (e) {
2057
+ writeErr(`error: ${e.message}\n`);
2058
+ return EXIT.USAGE;
2059
+ }
2060
+ return runValueProof(opts, io).code;
2061
+ }
2062
+
2063
+ // ---------------------------------------------------------------------------
2064
+ // `vh trust verify-seal <sealfile>` (T-26.2) — read-only, OFFLINE seal verify
2065
+ // ---------------------------------------------------------------------------
2066
+ //
2067
+ // The independent companion to `reconcile --seal`. Given ONLY the seal file (+
2068
+ // the files it names), re-derive each listed file's content hash and the manifest
2069
+ // root from the bytes on disk and compare against the seal's stored expectation.
2070
+ // It needs NO key, NO network, NO contract — purely the seal core's `verifySeal`.
2071
+ //
2072
+ // Files are resolved RELATIVE TO the seal file's directory by default (the seal
2073
+ // stores relPaths relative to where it was written), or relative to --dir. Prints
2074
+ // ACCEPTED only when EVERY sealed file MATCHes (no CHANGED/MISSING/UNEXPECTED, no
2075
+ // role swap, AND the root re-derives); otherwise REJECTED with the precise per-file
2076
+ // list and a non-zero exit. Exit contract mirrors the rest of the family:
2077
+ // 0 ACCEPTED, 3 REJECTED, 2 usage (bad flag), 1 IO (unreadable/missing seal).
2078
+ //
2079
+ // The output LEADS with the standing custodian/trust caveat + the seal posture
2080
+ // (tamper-evidence, NOT a trusted timestamp; the CPA review still governs).
2081
+
2082
+ // The caveat the verify-seal output LEADS with — the custodian responsibility +
2083
+ // the honest seal posture. Stated here so the human + JSON paths agree.
2084
+ const VERIFY_SEAL_CAVEAT =
2085
+ "The broker remains the responsible trust-account custodian. A seal is TAMPER-EVIDENT, " +
2086
+ "NOT a trusted timestamp (a matching seal proves the bytes are byte-for-byte what was " +
2087
+ "sealed, NOT when the sealing happened) and NOT a legal opinion (the CPA review still " +
2088
+ "governs). verify-seal RE-DERIVES the root from the files on disk — it never trusts the " +
2089
+ "seal's own stored hashes.";
2090
+
2091
+ // Parse `verify-seal` argv: one positional <sealfile>, plus --dir / --json. Unknown
2092
+ // flags and a missing/duplicate positional are USAGE errors (parser parity with
2093
+ // reconcile/inspect — a typo never silently changes what is verified).
2094
+ function parseVerifySealArgs(argv) {
2095
+ const opts = { sealfile: undefined, dir: undefined, inputsDir: undefined, json: false, _positionals: [] };
2096
+ for (let i = 0; i < argv.length; i++) {
2097
+ const a = argv[i];
2098
+ switch (a) {
2099
+ case "--dir":
2100
+ opts.dir = argv[++i];
2101
+ if (opts.dir === undefined) {
2102
+ const e = new Error("--dir requires a value");
2103
+ e.usage = true;
2104
+ throw e;
2105
+ }
2106
+ break;
2107
+ case "--inputs":
2108
+ // Locate the SOURCE inputs (bank/book/rentroll) in a dir distinct from the
2109
+ // packet outputs. Default: the same base dir as the outputs (the portable
2110
+ // handoff ships the sources NEXT TO the seal). Useful when the examiner keeps
2111
+ // the originals in a separate folder from the emitted packet.
2112
+ opts.inputsDir = argv[++i];
2113
+ if (opts.inputsDir === undefined) {
2114
+ const e = new Error("--inputs requires a value");
2115
+ e.usage = true;
2116
+ throw e;
2117
+ }
2118
+ break;
2119
+ case "--json":
2120
+ opts.json = true;
2121
+ break;
2122
+ default:
2123
+ if (a && a.startsWith("--")) {
2124
+ const e = new Error(`unknown option: ${a}`);
2125
+ e.usage = true;
2126
+ throw e;
2127
+ }
2128
+ opts._positionals.push(a);
2129
+ }
2130
+ }
2131
+ if (opts._positionals.length > 1) {
2132
+ const e = new Error(
2133
+ `unexpected extra argument: ${opts._positionals[1]} ` +
2134
+ "(verify-seal takes exactly one <sealfile>)"
2135
+ );
2136
+ e.usage = true;
2137
+ throw e;
2138
+ }
2139
+ opts.sealfile = opts._positionals[0];
2140
+ return opts;
2141
+ }
2142
+
2143
+ // Render the human verify-seal report. PURE: takes the verifySeal result + context,
2144
+ // returns a string. Leads with the caveat, then the verdict + the precise per-file
2145
+ // CHANGED/MISSING/UNEXPECTED/role lists.
2146
+ function renderVerifySeal(result, ctx) {
2147
+ const L = [];
2148
+ L.push(`# vh trust verify-seal — ${ctx.sealfile}`);
2149
+ L.push(VERIFY_SEAL_CAVEAT);
2150
+ L.push("");
2151
+ L.push(`sealed root: ${result.sealedRoot}`);
2152
+ L.push(`recomputed root: ${result.recomputedRoot}`);
2153
+ L.push(`root matches: ${result.rootMatches ? "yes" : "NO"}`);
2154
+ L.push(
2155
+ `sealed verdict: ${ctx.verdict.pass ? "PASS" : "FAIL"} ` +
2156
+ `(reportDate ${ctx.verdict.reportDate}` +
2157
+ `${ctx.verdict.period ? `, period ${ctx.verdict.period}` : ""})`
2158
+ );
2159
+ L.push(
2160
+ `files: ${result.counts.matched} matched, ${result.counts.changed} changed, ` +
2161
+ `${result.counts.missing} missing, ${result.counts.unexpected} unexpected, ` +
2162
+ `${result.counts.roleMismatched} role-mismatched`
2163
+ );
2164
+ L.push("");
2165
+ if (result.accepted) {
2166
+ L.push("ACCEPTED — every sealed file re-derives byte-for-byte and the root matches.");
2167
+ } else {
2168
+ L.push("REJECTED — the files on disk do NOT match the seal:");
2169
+ for (const c of result.changed) {
2170
+ L.push(
2171
+ ` CHANGED ${c.relPath}${c.role ? ` (${c.role})` : ""}: ` +
2172
+ `sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`
2173
+ );
2174
+ }
2175
+ for (const m of result.missing) {
2176
+ L.push(` MISSING ${m.relPath}${m.role ? ` (${m.role})` : ""}: sealed but not found on disk`);
2177
+ }
2178
+ for (const u of result.unexpected) {
2179
+ L.push(` UNEXPECTED ${u.relPath}: on disk but not named in the seal`);
2180
+ }
2181
+ for (const r of result.roleMismatches) {
2182
+ L.push(
2183
+ ` ROLE ${r.relPath}: sealed as ${r.sealedRole} but supplied as ${r.suppliedRole}`
2184
+ );
2185
+ }
2186
+ if (!result.rootMatches && result.changed.length === 0 && result.missing.length === 0 &&
2187
+ result.unexpected.length === 0 && result.roleMismatches.length === 0) {
2188
+ L.push(" ROOT the recomputed root does not equal the sealed root");
2189
+ }
2190
+ }
2191
+ L.push("");
2192
+ return L.join("\n");
2193
+ }
2194
+
2195
+ // runVerifySeal: load the seal, resolve + read every listed file, recompute via
2196
+ // verifySeal, print the verdict, return { code, result }. Read-only — writes NOTHING.
2197
+ function runVerifySeal(opts, io = {}) {
2198
+ const write = io.write || ((s) => process.stdout.write(s));
2199
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2200
+
2201
+ if (!opts.sealfile) {
2202
+ writeErr("error: `vh trust verify-seal` requires a <sealfile>\n");
2203
+ return { code: EXIT.USAGE };
2204
+ }
2205
+
2206
+ // Load + STRICT-validate the seal BEFORE any sealed file is read — a malformed or
2207
+ // missing seal hard-errors loudly (exit 1), never half-accepted nor treated as
2208
+ // "everything changed".
2209
+ const sealPath = path.resolve(opts.sealfile);
2210
+ let sealText;
2211
+ try {
2212
+ sealText = fs.readFileSync(sealPath, "utf8");
2213
+ } catch (e) {
2214
+ writeErr(`error: cannot read seal file ${opts.sealfile}: ${e.message}\n`);
2215
+ return { code: EXIT.IO };
2216
+ }
2217
+ let sealObj;
2218
+ try {
2219
+ sealObj = seal.readSeal(sealText);
2220
+ } catch (e) {
2221
+ writeErr(`error: invalid seal file ${opts.sealfile}: ${e.message}\n`);
2222
+ return { code: EXIT.IO };
2223
+ }
2224
+
2225
+ // Resolve OUTPUT files relative to --dir (if given) else the seal file's own
2226
+ // directory — the seal stored output relPaths relative to where it was written.
2227
+ // INPUT files (the bank/book/rentroll sources, sealed by basename) resolve relative
2228
+ // to --inputs (if given) else the SAME base dir as the outputs — the portable
2229
+ // handoff ships the sources next to the seal, so the default just works.
2230
+ const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(sealPath);
2231
+ const inputsDir = opts.inputsDir != null ? path.resolve(opts.inputsDir) : baseDir;
2232
+
2233
+ // Read every sealed entry's bytes from disk. A file the seal NAMES but that is
2234
+ // absent must NOT abort — it is a MISSING finding the verify localizes. So we
2235
+ // skip unreadable sealed files here (omitting them from the supplied set makes
2236
+ // verifySeal report them MISSING); a present file's broken read surfaces the same
2237
+ // way. Only the SEAL itself being unreadable is the IO hard-error above. verifySeal
2238
+ // tolerates a PARTIAL supplied set, so even an all-absent set routes through it and
2239
+ // is localized honestly (present files are recomputed; only genuinely-absent ones
2240
+ // are MISSING) — no synthesized "everything missing" shortcut that would mislabel a
2241
+ // co-located packet file as MISSING (REWORK Finding 2).
2242
+ const files = { inputs: [], outputs: [] };
2243
+ for (const e of sealObj.inputs) {
2244
+ const abs = path.resolve(inputsDir, e.relPath);
2245
+ let bytes;
2246
+ try {
2247
+ bytes = fs.readFileSync(abs);
2248
+ } catch (_) {
2249
+ continue; // absent -> verifySeal reports MISSING
2250
+ }
2251
+ files.inputs.push({ role: e.role, relPath: e.relPath, bytes });
2252
+ }
2253
+ for (const e of sealObj.outputs) {
2254
+ const abs = path.resolve(baseDir, e.relPath);
2255
+ let bytes;
2256
+ try {
2257
+ bytes = fs.readFileSync(abs);
2258
+ } catch (_) {
2259
+ continue; // absent -> verifySeal reports MISSING
2260
+ }
2261
+ files.outputs.push({ relPath: e.relPath, bytes });
2262
+ }
2263
+
2264
+ let result;
2265
+ try {
2266
+ result = seal.verifySeal(sealObj, files);
2267
+ } catch (e) {
2268
+ writeErr(`error: ${e.message}\n`);
2269
+ return { code: EXIT.IO };
2270
+ }
2271
+
2272
+ const code = result.accepted ? EXIT.PASS : EXIT.FAIL;
2273
+
2274
+ if (opts.json) {
2275
+ write(
2276
+ JSON.stringify(
2277
+ {
2278
+ ...result,
2279
+ sealfile: opts.sealfile,
2280
+ dir: baseDir,
2281
+ inputsDir,
2282
+ verdictSealed: sealObj.verdict,
2283
+ caveat: VERIFY_SEAL_CAVEAT,
2284
+ },
2285
+ null,
2286
+ 2
2287
+ ) + "\n"
2288
+ );
2289
+ } else {
2290
+ write(renderVerifySeal(result, { sealfile: opts.sealfile, verdict: sealObj.verdict }));
2291
+ }
2292
+
2293
+ return { code, result };
2294
+ }
2295
+
2296
+ function cmdVerifySeal(argv, io = {}) {
2297
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2298
+ let opts;
2299
+ try {
2300
+ opts = parseVerifySealArgs(argv);
2301
+ } catch (e) {
2302
+ writeErr(`error: ${e.message}\n`);
2303
+ return EXIT.USAGE;
2304
+ }
2305
+ return runVerifySeal(opts, io).code;
2306
+ }
2307
+
2308
+ // ---------------------------------------------------------------------------
2309
+ // `vh trust serve [--port <n>] [--host <h>] [--out <dir>]` (T-27.3)
2310
+ // ---------------------------------------------------------------------------
2311
+ //
2312
+ // Launch the local web front-door over the engine — the broker-facing door so a
2313
+ // non-technical custodian can open a browser, drop their three monthly files, and
2314
+ // watch the balances tie out WITHOUT a terminal. It REUSES `server.js` VERBATIM:
2315
+ // this is only the CLI plumbing that parses the port/host, binds the http.Server,
2316
+ // and prints the URL. The pipeline itself is unchanged.
2317
+ //
2318
+ // FILE PRIVACY POSTURE (stated in-band + in docs): the server processes the three
2319
+ // uploaded files PURELY in memory and persists NOTHING server-side. There is no
2320
+ // `--out` for serve — a long-lived public server must never silently accumulate a
2321
+ // broker's trust-account files on its disk. (The CLI `vh trust reconcile --out` is
2322
+ // the path that WRITES a packet, and only to a caller-chosen dir.)
2323
+ //
2324
+ // HUMAN DEPLOY STEP (never auto-deployed): this binds to LOCALHOST by default and
2325
+ // is meant to be run locally or behind the broker's OWN nginx/Cloudflare on their
2326
+ // OWN domain with TLS. The loop NEVER deploys it to a public network.
2327
+ //
2328
+ // Exit contract: this command does not "complete" — it LISTENS until killed. The
2329
+ // runner returns { code, server, url } so a test can start it, hit it, and close
2330
+ // it; `code` is only meaningful for the early-exit USAGE error (a bad --port).
2331
+
2332
+ const SERVE_DEFAULT_PORT = 4173;
2333
+ const SERVE_DEFAULT_HOST = "127.0.0.1";
2334
+
2335
+ // Parse `serve` argv into options. Flags only (no positionals). An unknown flag or
2336
+ // a positional is a USAGE error, matching the rest of the family.
2337
+ function parseServeArgs(argv) {
2338
+ const opts = { port: undefined, host: undefined, _positionals: [] };
2339
+ for (let i = 0; i < argv.length; i++) {
2340
+ const a = argv[i];
2341
+ switch (a) {
2342
+ case "--port":
2343
+ opts.port = parsePortArg(argv[++i]);
2344
+ break;
2345
+ case "--host":
2346
+ opts.host = argv[++i];
2347
+ if (opts.host === undefined) {
2348
+ const e = new Error("--host requires a value");
2349
+ e.usage = true;
2350
+ throw e;
2351
+ }
2352
+ break;
2353
+ default:
2354
+ if (a && a.startsWith("--")) {
2355
+ const e = new Error(`unknown option: ${a}`);
2356
+ e.usage = true;
2357
+ throw e;
2358
+ }
2359
+ opts._positionals.push(a);
2360
+ }
2361
+ }
2362
+ if (opts._positionals.length > 0) {
2363
+ const e = new Error(
2364
+ `unexpected argument: ${opts._positionals[0]} (serve takes no positionals)`
2365
+ );
2366
+ e.usage = true;
2367
+ throw e;
2368
+ }
2369
+ return opts;
2370
+ }
2371
+
2372
+ // A --port must be an integer in the valid TCP range (1..65535) OR 0 (bind an
2373
+ // EPHEMERAL port — useful for tests and for "pick any free port"). A bad value is
2374
+ // a USAGE error (exit 2), never silently coerced.
2375
+ function parsePortArg(raw) {
2376
+ const s = String(raw == null ? "" : raw);
2377
+ if (!/^\d+$/.test(s)) {
2378
+ const e = new Error(`--port must be a non-negative integer (got "${raw}")`);
2379
+ e.usage = true;
2380
+ throw e;
2381
+ }
2382
+ const n = Number(s);
2383
+ if (n > 65535) {
2384
+ const e = new Error(`--port must be in 0..65535 (got "${raw}")`);
2385
+ e.usage = true;
2386
+ throw e;
2387
+ }
2388
+ return n;
2389
+ }
2390
+
2391
+ // runServe binds the server and prints the URL. It does NOT block; it returns
2392
+ // { code, server, url } once listening (or { code: USAGE } on a bad flag without
2393
+ // ever binding). `io.listen` is injectable so a test can confirm the wiring without
2394
+ // the runner picking a port itself; the default builds + listens on a real socket.
2395
+ function runServe(opts, io = {}) {
2396
+ const write = io.write || ((s) => process.stdout.write(s));
2397
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2398
+
2399
+ const port = opts.port == null ? SERVE_DEFAULT_PORT : opts.port;
2400
+ const host = opts.host == null ? SERVE_DEFAULT_HOST : opts.host;
2401
+
2402
+ const srv = (io.createServer || server.createServer)({ today: io.today });
2403
+
2404
+ return new Promise((resolve) => {
2405
+ // Guard against resolving twice: a bind failure fires 'error' and the listen
2406
+ // callback never runs, but a defensive flag keeps the two paths exclusive.
2407
+ let settled = false;
2408
+
2409
+ // Surface a bind failure (e.g. EADDRINUSE, EACCES on a privileged port, a bad
2410
+ // --host interface) as a clear IO error AND resolve the Promise with EXIT.IO so
2411
+ // the failure propagates to the process exit code. Without this resolve the
2412
+ // Promise would hang forever; on the real CLI path the failed server holds no
2413
+ // event-loop handles, so Node would exit ON ITS OWN with code 0 — collapsing
2414
+ // the IO(1) failure class into PASS(0). A supervisor / systemd / CI healthcheck
2415
+ // running `vh trust serve || alert` must see a non-zero code when the door
2416
+ // failed to bind.
2417
+ srv.on("error", (e) => {
2418
+ if (settled) return;
2419
+ settled = true;
2420
+ writeErr(`error: cannot start TrustLedger web door: ${e.message}\n`);
2421
+ resolve({ code: EXIT.IO, server: srv, url: null, error: e });
2422
+ });
2423
+
2424
+ srv.listen(port, host, () => {
2425
+ if (settled) return;
2426
+ settled = true;
2427
+ // When --port 0 was given the OS chose the actual port; report the real one.
2428
+ const bound = srv.address();
2429
+ const realPort = bound && typeof bound === "object" ? bound.port : port;
2430
+ const url = `http://${host}:${realPort}/`;
2431
+ // 0.0.0.0 (or ::) is a bind target, not a browsable address; tell an operator
2432
+ // who bound all interfaces to reach it via their machine's real address.
2433
+ const browseHint =
2434
+ host === "0.0.0.0" || host === "::"
2435
+ ? " (0.0.0.0 binds ALL interfaces — browse via your machine's own address.)\n"
2436
+ : "";
2437
+ write(
2438
+ `TrustLedger web door listening on ${url}\n` +
2439
+ browseHint +
2440
+ " Files are processed IN MEMORY; nothing is written to disk server-side.\n" +
2441
+ " This binds to localhost — to expose it, put it behind YOUR nginx/Cloudflare\n" +
2442
+ " on YOUR own domain with TLS (a human deploy step; it is never auto-deployed).\n" +
2443
+ " Press Ctrl-C to stop.\n"
2444
+ );
2445
+ resolve({ code: EXIT.PASS, server: srv, url });
2446
+ });
2447
+ });
2448
+ }
2449
+
2450
+ // cmdServe: parse argv, then bind + print. The dispatcher (`vh trust`) awaits a
2451
+ // PLAIN exit code, so this resolves to a NUMBER:
2452
+ // * a bad flag resolves immediately to EXIT.USAGE (2) and the process exits, OR
2453
+ // * a BIND FAILURE (EADDRINUSE / EACCES / bad --host) resolves to EXIT.IO (1) so
2454
+ // the failed door propagates a non-zero exit instead of letting Node exit 0, OR
2455
+ // * on success it binds, prints the URL, and returns a Promise that NEVER
2456
+ // resolves — the open socket keeps the event loop alive so the door stays up
2457
+ // until the operator kills it (Ctrl-C), exactly like a normal server process.
2458
+ // Tests call `runServe` directly (which resolves with the live { server } handle)
2459
+ // for the success path so they can hit it and close it; the bind-failure path is
2460
+ // exercised through cmdServe to assert the EXIT.IO exit code.
2461
+ function cmdServe(argv, io = {}) {
2462
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2463
+ let opts;
2464
+ try {
2465
+ opts = parseServeArgs(argv);
2466
+ } catch (e) {
2467
+ writeErr(`error: ${e.message}\n`);
2468
+ return Promise.resolve(EXIT.USAGE);
2469
+ }
2470
+ return runServe(opts, io).then((res) => {
2471
+ // A non-PASS resolved code (a bind failure => EXIT.IO) maps STRAIGHT to that
2472
+ // number so the process exits non-zero. Only the listening (PASS) case holds
2473
+ // the process open forever on the live socket.
2474
+ if (res.code !== EXIT.PASS) return res.code;
2475
+ return new Promise(() => {});
2476
+ });
2477
+ }
2478
+
2479
+ // ---------------------------------------------------------------------------
2480
+ // `vh trust license issue | verify` (T-29.2) — mint + OFFLINE-verify a product
2481
+ // license. `issue` reads a HUMAN-supplied key (EXACTLY ONE of --key-env/--key-file,
2482
+ // reused-then-discarded, NEVER written/logged/echoed — the exact key-handling
2483
+ // posture of `vh dataset sign`), signs a license via the shared license core, and
2484
+ // prints ONLY the PUBLIC vendor address + the license summary + the path. `verify`
2485
+ // is read-only, OFFLINE, key-free: it prints VALID/INVALID + the precise reason +
2486
+ // entitlements + expiry, exiting 0 (valid) / 3 (invalid) just like verifyLicense.
2487
+ // ---------------------------------------------------------------------------
2488
+
2489
+ const coreAttestation = require("../cli/core/attestation");
2490
+
2491
+ // Parse `license issue` argv. EXACTLY-ONE-of key sources is enforced downstream by
2492
+ // loadSigningWallet (so neither/both error key-free); the parser only collects flags.
2493
+ function parseLicenseIssueArgs(argv) {
2494
+ const opts = {
2495
+ customer: undefined,
2496
+ plan: undefined,
2497
+ entitlements: undefined, // comma-separated -> array
2498
+ expires: undefined, // ISO instant
2499
+ issued: undefined, // OPTIONAL ISO instant; default "now" supplied by the command
2500
+ licenseId: undefined, // OPTIONAL; defaulted by the command when omitted
2501
+ keyEnv: undefined,
2502
+ keyFile: undefined,
2503
+ out: undefined,
2504
+ json: false,
2505
+ };
2506
+ for (let i = 0; i < argv.length; i++) {
2507
+ const a = argv[i];
2508
+ const need = () => {
2509
+ const v = argv[++i];
2510
+ if (v === undefined || String(v).startsWith("--")) {
2511
+ const e = new Error(`${a} requires a value`);
2512
+ e.usage = true;
2513
+ throw e;
2514
+ }
2515
+ return v;
2516
+ };
2517
+ switch (a) {
2518
+ case "--customer": opts.customer = need(); break;
2519
+ case "--plan": opts.plan = need(); break;
2520
+ case "--entitlements": opts.entitlements = need(); break;
2521
+ case "--expires": opts.expires = need(); break;
2522
+ case "--issued": opts.issued = need(); break;
2523
+ case "--license-id": opts.licenseId = need(); break;
2524
+ case "--key-env": opts.keyEnv = need(); break;
2525
+ case "--key-file": opts.keyFile = need(); break;
2526
+ case "--out": opts.out = need(); break;
2527
+ case "--json": opts.json = true; break;
2528
+ default: {
2529
+ const e = new Error(`unknown option: ${a}`);
2530
+ e.usage = true;
2531
+ throw e;
2532
+ }
2533
+ }
2534
+ }
2535
+ return opts;
2536
+ }
2537
+
2538
+ async function cmdLicenseIssue(argv, io = {}) {
2539
+ const write = io.write || ((s) => process.stdout.write(s));
2540
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2541
+ let opts;
2542
+ try {
2543
+ opts = parseLicenseIssueArgs(argv);
2544
+ } catch (e) {
2545
+ writeErr(`error: ${e.message}\n`);
2546
+ return EXIT.USAGE;
2547
+ }
2548
+
2549
+ // Required license fields (the key sources are validated by loadSigningWallet).
2550
+ for (const [flag, val] of [
2551
+ ["--customer", opts.customer],
2552
+ ["--plan", opts.plan],
2553
+ ["--entitlements", opts.entitlements],
2554
+ ["--expires", opts.expires],
2555
+ ]) {
2556
+ if (val == null) {
2557
+ writeErr(`error: \`vh trust license issue\` requires ${flag}\n`);
2558
+ return EXIT.USAGE;
2559
+ }
2560
+ }
2561
+
2562
+ // Resolve the HUMAN-supplied key into an in-process Wallet FIRST, BEFORE building
2563
+ // anything — neither/both sources, a missing env var, an unreadable file, or a
2564
+ // malformed/zero key hard-errors here with a KEY-FREE message (the SAME core +
2565
+ // posture as `vh dataset sign`). The loop never holds a key.
2566
+ let wallet;
2567
+ try {
2568
+ ({ wallet } = coreAttestation.loadSigningWallet({ keyEnv: opts.keyEnv, keyFile: opts.keyFile }));
2569
+ } catch (e) {
2570
+ writeErr(`error: ${e.message}\n`);
2571
+ return EXIT.USAGE;
2572
+ }
2573
+
2574
+ // Assemble the license fields. issuedAt defaults to the injectable clock (a real
2575
+ // ISO instant at runtime; a pinned one in tests). entitlements is a comma list.
2576
+ const issuedAt = opts.issued != null ? opts.issued : (io.nowISO || nowISO)();
2577
+ const entitlements = String(opts.entitlements)
2578
+ .split(",")
2579
+ .map((s) => s.trim())
2580
+ .filter((s) => s.length > 0);
2581
+ const licenseId =
2582
+ opts.licenseId != null && opts.licenseId !== ""
2583
+ ? opts.licenseId
2584
+ : `LIC-${issuedAt}-${opts.plan}`;
2585
+
2586
+ let container;
2587
+ try {
2588
+ container = await license.buildLicense(
2589
+ {
2590
+ licenseId,
2591
+ customer: opts.customer,
2592
+ plan: opts.plan,
2593
+ entitlements,
2594
+ issuedAt,
2595
+ expiresAt: opts.expires,
2596
+ },
2597
+ wallet
2598
+ );
2599
+ } catch (e) {
2600
+ // A LicenseError (bad date, unknown entitlement, expiresAt<=issuedAt, …) is a
2601
+ // usage error — NEVER echo the key (a build error carries only the bad field).
2602
+ writeErr(`error: ${e.message}\n`);
2603
+ return EXIT.USAGE;
2604
+ }
2605
+
2606
+ const canonical = license.serializeSignedLicense(container);
2607
+ // The PUBLIC vendor address — recovered from the signature, never the key.
2608
+ const vendor = coreAttestation.recoverSigner(container);
2609
+ const payload = JSON.parse(container.attestation);
2610
+
2611
+ let outAbs = null;
2612
+ if (opts.out) {
2613
+ outAbs = path.resolve(opts.out);
2614
+ try {
2615
+ fs.writeFileSync(outAbs, canonical);
2616
+ } catch (e) {
2617
+ writeErr(`error: cannot write --out license file ${opts.out}: ${e.message}\n`);
2618
+ return EXIT.IO;
2619
+ }
2620
+ }
2621
+
2622
+ if (opts.json) {
2623
+ // ONLY public fields: vendor ADDRESS, the license summary, the path — NEVER the
2624
+ // key. With no --out the canonical bytes ride in `container` (artifact parity).
2625
+ write(
2626
+ JSON.stringify(
2627
+ {
2628
+ issued: true,
2629
+ vendor,
2630
+ licenseId: payload.licenseId,
2631
+ customer: payload.customer,
2632
+ plan: payload.plan,
2633
+ entitlements: payload.entitlements,
2634
+ issuedAt: payload.issuedAt,
2635
+ expiresAt: payload.expiresAt,
2636
+ out: outAbs,
2637
+ container: outAbs ? null : canonical,
2638
+ },
2639
+ null,
2640
+ 2
2641
+ ) + "\n"
2642
+ );
2643
+ } else {
2644
+ write(`issued TrustLedger license by vendor ${vendor}\n`);
2645
+ write(` licenseId: ${payload.licenseId}\n`);
2646
+ write(` customer: ${payload.customer}\n`);
2647
+ write(` plan: ${payload.plan}\n`);
2648
+ write(` entitlements: ${payload.entitlements.join(", ")}\n`);
2649
+ write(` issuedAt: ${payload.issuedAt}\n`);
2650
+ write(` expiresAt: ${payload.expiresAt}\n`);
2651
+ if (outAbs) {
2652
+ write(` written: ${outAbs}\n`);
2653
+ } else {
2654
+ // No --out: emit the canonical signed bytes after the human header.
2655
+ write(canonical);
2656
+ }
2657
+ }
2658
+ return EXIT.PASS;
2659
+ }
2660
+
2661
+ // ---------------------------------------------------------------------------
2662
+ // `vh trust license fulfill` (T-37.2) — the order -> license mapping as a command.
2663
+ //
2664
+ // The self-serve fulfillment seam: given the planId a customer bought (+ their
2665
+ // name, when the period is paid through), resolve it in the plan catalog, copy the
2666
+ // plan's entitlements VERBATIM, derive the [issuedAt, expiresAt] window, and emit
2667
+ // the SAME signed `*.vhlicense.json` the existing `verify` / reconcile gate already
2668
+ // accept — so a billing webhook's fulfillment handler is ONE command per sale.
2669
+ //
2670
+ // The catalog is the BUNDLED baseline by default (the seller's reviewed price-list,
2671
+ // shipped as a DRAFT skeleton — set YOUR price/term per planId), or an explicit
2672
+ // `--catalog <file>`. The key is read the EXACT read-used-discarded way `license
2673
+ // issue` / `vh dataset sign` read it (EXACTLY ONE of --key-env/--key-file; the loop
2674
+ // NEVER holds the key, NEVER echoes it). Entitlements are NEVER hand-typed here —
2675
+ // they come ONLY from the resolved plan, so a typo can never mis-entitle a sale.
2676
+ // ---------------------------------------------------------------------------
2677
+
2678
+ // The bundled DRAFT plan catalog `fulfill` resolves a plan against when no
2679
+ // --catalog is given. Read from THIS package's own fixtures dir — never a caller
2680
+ // path — so the default resolution is deterministic and self-contained.
2681
+ const BUNDLED_CATALOG = path.join(__dirname, "fixtures", "plans", "baseline.json");
2682
+
2683
+ // Parse `license fulfill` argv. EXACTLY-ONE-of key sources is enforced downstream
2684
+ // by loadSigningWallet (so neither/both error key-free); the parser only collects.
2685
+ function parseLicenseFulfillArgs(argv) {
2686
+ const opts = {
2687
+ plan: undefined, // a planId in the catalog
2688
+ customer: undefined,
2689
+ paidThrough: undefined, // OPTIONAL ISO instant; default = issuedAt + plan term
2690
+ issued: undefined, // OPTIONAL ISO instant; default "now" supplied by the command
2691
+ licenseId: undefined, // OPTIONAL; defaulted deterministically by fulfillOrder
2692
+ catalog: undefined, // OPTIONAL path to a plan catalog JSON; default = bundled baseline
2693
+ keyEnv: undefined,
2694
+ keyFile: undefined,
2695
+ out: undefined,
2696
+ json: false,
2697
+ };
2698
+ for (let i = 0; i < argv.length; i++) {
2699
+ const a = argv[i];
2700
+ const need = () => {
2701
+ const v = argv[++i];
2702
+ if (v === undefined || String(v).startsWith("--")) {
2703
+ const e = new Error(`${a} requires a value`);
2704
+ e.usage = true;
2705
+ throw e;
2706
+ }
2707
+ return v;
2708
+ };
2709
+ switch (a) {
2710
+ case "--plan": opts.plan = need(); break;
2711
+ case "--customer": opts.customer = need(); break;
2712
+ case "--paid-through": opts.paidThrough = need(); break;
2713
+ case "--issued": opts.issued = need(); break;
2714
+ case "--license-id": opts.licenseId = need(); break;
2715
+ case "--catalog": opts.catalog = need(); break;
2716
+ case "--key-env": opts.keyEnv = need(); break;
2717
+ case "--key-file": opts.keyFile = need(); break;
2718
+ case "--out": opts.out = need(); break;
2719
+ case "--json": opts.json = true; break;
2720
+ default: {
2721
+ const e = new Error(`unknown option: ${a}`);
2722
+ e.usage = true;
2723
+ throw e;
2724
+ }
2725
+ }
2726
+ }
2727
+ return opts;
2728
+ }
2729
+
2730
+ async function cmdLicenseFulfill(argv, io = {}) {
2731
+ const write = io.write || ((s) => process.stdout.write(s));
2732
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2733
+ let opts;
2734
+ try {
2735
+ opts = parseLicenseFulfillArgs(argv);
2736
+ } catch (e) {
2737
+ writeErr(`error: ${e.message}\n`);
2738
+ return EXIT.USAGE;
2739
+ }
2740
+
2741
+ // Required order fields (the key sources are validated by loadSigningWallet; the
2742
+ // plan is resolved against the catalog by fulfillOrder).
2743
+ for (const [flag, val] of [
2744
+ ["--plan", opts.plan],
2745
+ ["--customer", opts.customer],
2746
+ ]) {
2747
+ if (val == null) {
2748
+ writeErr(`error: \`vh trust license fulfill\` requires ${flag}\n`);
2749
+ return EXIT.USAGE;
2750
+ }
2751
+ }
2752
+
2753
+ // Load + strictly validate the plan catalog (bundled baseline by default). A
2754
+ // malformed/unreadable catalog is a usage error (a bad data file, not an IO crash).
2755
+ const catalogPath = opts.catalog != null ? path.resolve(opts.catalog) : BUNDLED_CATALOG;
2756
+ let catalog;
2757
+ try {
2758
+ const text = fs.readFileSync(catalogPath, "utf8");
2759
+ catalog = plans.validatePlanCatalog(JSON.parse(text));
2760
+ } catch (e) {
2761
+ writeErr(`error: cannot load plan catalog ${catalogPath}: ${e.message}\n`);
2762
+ return EXIT.USAGE;
2763
+ }
2764
+
2765
+ // Resolve the HUMAN-supplied key into an in-process Wallet FIRST, BEFORE building
2766
+ // anything — neither/both sources, a missing env var, an unreadable file, or a
2767
+ // malformed/zero key hard-errors here with a KEY-FREE message (the SAME core +
2768
+ // posture as `license issue` / `vh dataset sign`). The loop never holds a key.
2769
+ let wallet;
2770
+ try {
2771
+ ({ wallet } = coreAttestation.loadSigningWallet({ keyEnv: opts.keyEnv, keyFile: opts.keyFile }));
2772
+ } catch (e) {
2773
+ writeErr(`error: ${e.message}\n`);
2774
+ return EXIT.USAGE;
2775
+ }
2776
+
2777
+ // issuedAt defaults to the injectable clock (a real ISO instant at runtime; a
2778
+ // pinned one in tests). The order -> license-params mapping is PURE + deterministic.
2779
+ const issuedAt = opts.issued != null ? opts.issued : (io.nowISO || nowISO)();
2780
+ let params;
2781
+ try {
2782
+ params = license.fulfillOrder(
2783
+ {
2784
+ plan: opts.plan,
2785
+ customer: opts.customer,
2786
+ issuedAt,
2787
+ paidThrough: opts.paidThrough != null ? opts.paidThrough : undefined,
2788
+ licenseId: opts.licenseId != null && opts.licenseId !== "" ? opts.licenseId : undefined,
2789
+ },
2790
+ catalog
2791
+ );
2792
+ } catch (e) {
2793
+ // An unknown plan / paidThrough<=issuedAt / malformed date is a usage error —
2794
+ // NEVER echo the key (a mapping error carries only the bad order field).
2795
+ writeErr(`error: ${e.message}\n`);
2796
+ return EXIT.USAGE;
2797
+ }
2798
+
2799
+ // Sign the derived params into the SAME signed container `issue` mints — the
2800
+ // existing verify / gate accept it byte-for-byte. No key handling here; the key
2801
+ // lives only inside `wallet`.
2802
+ let container;
2803
+ try {
2804
+ container = await license.buildLicense(params, wallet);
2805
+ } catch (e) {
2806
+ writeErr(`error: ${e.message}\n`);
2807
+ return EXIT.USAGE;
2808
+ }
2809
+
2810
+ const canonical = license.serializeSignedLicense(container);
2811
+ // The PUBLIC vendor address — recovered from the signature, never the key.
2812
+ const vendor = coreAttestation.recoverSigner(container);
2813
+ const payload = JSON.parse(container.attestation);
2814
+
2815
+ let outAbs = null;
2816
+ if (opts.out) {
2817
+ outAbs = path.resolve(opts.out);
2818
+ try {
2819
+ fs.writeFileSync(outAbs, canonical);
2820
+ } catch (e) {
2821
+ writeErr(`error: cannot write --out license file ${opts.out}: ${e.message}\n`);
2822
+ return EXIT.IO;
2823
+ }
2824
+ }
2825
+
2826
+ if (opts.json) {
2827
+ // ONLY public fields: vendor ADDRESS, the license summary, the path — NEVER the
2828
+ // key. With no --out the canonical bytes ride in `container` (artifact parity).
2829
+ write(
2830
+ JSON.stringify(
2831
+ {
2832
+ fulfilled: true,
2833
+ vendor,
2834
+ licenseId: payload.licenseId,
2835
+ customer: payload.customer,
2836
+ plan: payload.plan,
2837
+ entitlements: payload.entitlements,
2838
+ issuedAt: payload.issuedAt,
2839
+ expiresAt: payload.expiresAt,
2840
+ out: outAbs,
2841
+ container: outAbs ? null : canonical,
2842
+ },
2843
+ null,
2844
+ 2
2845
+ ) + "\n"
2846
+ );
2847
+ } else {
2848
+ write(`fulfilled TrustLedger license for plan ${payload.plan} by vendor ${vendor}\n`);
2849
+ write(` licenseId: ${payload.licenseId}\n`);
2850
+ write(` customer: ${payload.customer}\n`);
2851
+ write(` plan: ${payload.plan}\n`);
2852
+ write(` entitlements: ${payload.entitlements.join(", ")}\n`);
2853
+ write(` issuedAt: ${payload.issuedAt}\n`);
2854
+ write(` expiresAt: ${payload.expiresAt}\n`);
2855
+ if (outAbs) {
2856
+ write(` written: ${outAbs}\n`);
2857
+ } else {
2858
+ // No --out: emit the canonical signed bytes after the human header.
2859
+ write(canonical);
2860
+ }
2861
+ }
2862
+ return EXIT.PASS;
2863
+ }
2864
+
2865
+ // Parse `license verify <file> --vendor <0xaddr> [--json] [--now <iso>]`.
2866
+ function parseLicenseVerifyArgs(argv) {
2867
+ const opts = { file: undefined, vendor: undefined, json: false, now: undefined, _positionals: [] };
2868
+ for (let i = 0; i < argv.length; i++) {
2869
+ const a = argv[i];
2870
+ switch (a) {
2871
+ case "--vendor": opts.vendor = argv[++i]; break;
2872
+ case "--now": opts.now = argv[++i]; break;
2873
+ case "--json": opts.json = true; break;
2874
+ default:
2875
+ if (a && a.startsWith("--")) {
2876
+ const e = new Error(`unknown option: ${a}`);
2877
+ e.usage = true;
2878
+ throw e;
2879
+ }
2880
+ opts._positionals.push(a);
2881
+ }
2882
+ }
2883
+ opts.file = opts._positionals[0];
2884
+ return opts;
2885
+ }
2886
+
2887
+ function cmdLicenseVerify(argv, io = {}) {
2888
+ const write = io.write || ((s) => process.stdout.write(s));
2889
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2890
+ let opts;
2891
+ try {
2892
+ opts = parseLicenseVerifyArgs(argv);
2893
+ } catch (e) {
2894
+ writeErr(`error: ${e.message}\n`);
2895
+ return EXIT.USAGE;
2896
+ }
2897
+ if (!opts.file) {
2898
+ writeErr("error: `vh trust license verify` requires a <file>\n");
2899
+ return EXIT.USAGE;
2900
+ }
2901
+ if (opts.vendor == null) {
2902
+ writeErr("error: `vh trust license verify` requires --vendor <0xaddr> (the issuer to pin to)\n");
2903
+ return EXIT.USAGE;
2904
+ }
2905
+
2906
+ let container;
2907
+ try {
2908
+ const text = fs.readFileSync(path.resolve(opts.file), "utf8");
2909
+ container = license.readLicense(text);
2910
+ } catch (e) {
2911
+ // A missing/garbled container is a malformed verdict (INVALID), not a crash, so
2912
+ // a scripted check sees the 3 exit + the reason — but we surface the IO cause.
2913
+ writeErr(`error: cannot read license file ${opts.file}: ${e.message}\n`);
2914
+ return EXIT.IO;
2915
+ }
2916
+
2917
+ // `now` is the injectable clock (a pinned instant in tests); default real now.
2918
+ const now = opts.now != null ? opts.now : (io.nowISO || nowISO)();
2919
+ let verdict;
2920
+ try {
2921
+ verdict = license.verifyLicense(container, { now, vendorAddress: opts.vendor });
2922
+ } catch (e) {
2923
+ // A malformed --vendor (or bad --now) is a caller error — usage, key-free.
2924
+ writeErr(`error: ${e.message}\n`);
2925
+ return EXIT.USAGE;
2926
+ }
2927
+
2928
+ // Read the embedded payload's expiry/entitlements for the report (present even on
2929
+ // an expired/wrong-issuer verdict, so the human sees WHAT was rejected).
2930
+ const payload = verdict.payload;
2931
+ if (opts.json) {
2932
+ write(
2933
+ JSON.stringify(
2934
+ {
2935
+ valid: verdict.valid,
2936
+ reason: verdict.reason, // EXACTLY as verifyLicense returns it
2937
+ vendor: verdict.vendorAddress,
2938
+ recoveredSigner: verdict.recoveredSigner,
2939
+ entitlements: payload ? payload.entitlements : [],
2940
+ issuedAt: payload ? payload.issuedAt : null,
2941
+ expiresAt: payload ? payload.expiresAt : null,
2942
+ now: verdict.now,
2943
+ },
2944
+ null,
2945
+ 2
2946
+ ) + "\n"
2947
+ );
2948
+ } else if (verdict.valid) {
2949
+ write("VALID\n");
2950
+ write(` vendor: ${verdict.vendorAddress}\n`);
2951
+ write(` customer: ${payload.customer}\n`);
2952
+ write(` plan: ${payload.plan}\n`);
2953
+ write(` entitlements: ${payload.entitlements.join(", ")}\n`);
2954
+ write(` expiresAt: ${payload.expiresAt}\n`);
2955
+ } else {
2956
+ write("INVALID\n");
2957
+ write(` reason: ${verdict.reason}\n`);
2958
+ if (payload) {
2959
+ write(` entitlements: ${payload.entitlements.join(", ")}\n`);
2960
+ write(` expiresAt: ${payload.expiresAt}\n`);
2961
+ }
2962
+ }
2963
+ // 0 valid / 3 invalid — the SAME verdict semantics as verifyLicense / verify-seal.
2964
+ return verdict.valid ? EXIT.PASS : EXIT.FAIL;
2965
+ }
2966
+
2967
+ // `vh trust license <issue|verify> ...` sub-dispatch.
2968
+ function cmdLicense(argv, io = {}) {
2969
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2970
+ const [sub, ...rest] = argv;
2971
+ if (sub === "issue") return cmdLicenseIssue(rest, io);
2972
+ if (sub === "fulfill") return cmdLicenseFulfill(rest, io);
2973
+ if (sub === "verify") return cmdLicenseVerify(rest, io);
2974
+ if (sub === "help" || sub === "-h" || sub === "--help") {
2975
+ (io.write || ((s) => process.stdout.write(s)))(licenseHelp());
2976
+ return EXIT.PASS;
2977
+ }
2978
+ writeErr(
2979
+ `error: unknown license subcommand: ${sub === undefined ? "(none)" : sub} ` +
2980
+ "(expected: issue, fulfill, verify)\n" +
2981
+ licenseHelp()
2982
+ );
2983
+ return EXIT.USAGE;
2984
+ }
2985
+
2986
+ function licenseHelp() {
2987
+ return [
2988
+ "vh trust license — issue + OFFLINE-verify a TrustLedger product license",
2989
+ "",
2990
+ " issue --customer <name> --plan <plan> --entitlements <a,b,c> --expires <ISO>",
2991
+ " (--key-env <VAR> | --key-file <path>) [--issued <ISO>] [--license-id <id>] [--out <file>] [--json]",
2992
+ " Sign a license with a key YOU supply at runtime (read-used-discarded, NEVER",
2993
+ " written/logged/echoed). Prints ONLY the public vendor address + the summary + path.",
2994
+ ` Entitlements (closed set): ${license.ENTITLEMENT_FLAGS.join(", ")}.`,
2995
+ "",
2996
+ " fulfill --plan <id> --customer <name> [--paid-through <ISO>] [--catalog <file>]",
2997
+ " (--key-env <VAR> | --key-file <path>) [--issued <ISO>] [--license-id <id>] [--out <file>] [--json]",
2998
+ " The order -> license mapping: resolve <id> in the plan catalog (bundled DRAFT baseline",
2999
+ " by default, or --catalog), copy that plan's entitlements VERBATIM, derive the window",
3000
+ " (--paid-through, else issuedAt + the plan's term), and emit the SAME signed license",
3001
+ " `verify` / the reconcile gate accept. Entitlements are NEVER hand-typed — a typo can't",
3002
+ " mis-entitle a sale. Same key posture as `issue` (read-used-discarded, never echoed).",
3003
+ "",
3004
+ " verify <file> --vendor <0xaddr> [--json] [--now <ISO>]",
3005
+ " Read-only, OFFLINE, key-free. Prints VALID/INVALID + reason + entitlements + expiry.",
3006
+ " Exit 0 valid / 3 invalid (reason: malformed|bad_signature|wrong_issuer|not_yet_valid|expired).",
3007
+ "",
3008
+ "A license GATES the paid reconcile surfaces (--state/--policy, --seal): pass",
3009
+ "`vh trust reconcile ... --license <file> --vendor <0xaddr>` to unlock them. The FREE",
3010
+ "tier (baseline reconcile + `vh trust inspect`) needs no license.",
3011
+ "",
3012
+ ].join("\n");
3013
+ }
3014
+
3015
+ // Real "now" as a canonical ISO-8601 UTC instant — the issuer/verify default clock,
3016
+ // isolated + injectable so the commands stay deterministic under test.
3017
+ function nowISO() {
3018
+ return new Date().toISOString();
3019
+ }
3020
+
3021
+ // ---------------------------------------------------------------------------
3022
+ // argv dispatch
3023
+ // ---------------------------------------------------------------------------
3024
+
3025
+ function cmdReconcile(argv, io = {}) {
3026
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
3027
+ let opts;
3028
+ try {
3029
+ opts = parseReconcileArgs(argv);
3030
+ } catch (e) {
3031
+ writeErr(`error: ${e.message}\n`);
3032
+ return EXIT.USAGE;
3033
+ }
3034
+ const res = runReconcile(opts, io);
3035
+ return res.code;
3036
+ }
3037
+
3038
+ // `vh trust <sub> ...` dispatcher.
3039
+ function cmdTrust(argv, io = {}) {
3040
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
3041
+ const [sub, ...rest] = argv;
3042
+ if (sub === "reconcile") {
3043
+ return cmdReconcile(rest, io);
3044
+ }
3045
+ if (sub === "inspect") {
3046
+ return cmdInspect(rest, io);
3047
+ }
3048
+ if (sub === "corpus") {
3049
+ return cmdCorpus(rest, io);
3050
+ }
3051
+ if (sub === "value-proof") {
3052
+ return cmdValueProof(rest, io);
3053
+ }
3054
+ if (sub === "verify-seal") {
3055
+ return cmdVerifySeal(rest, io);
3056
+ }
3057
+ if (sub === "serve") {
3058
+ return cmdServe(rest, io);
3059
+ }
3060
+ if (sub === "license") {
3061
+ return cmdLicense(rest, io);
3062
+ }
3063
+ if (sub === "help" || sub === "-h" || sub === "--help") {
3064
+ (io.write || ((s) => process.stdout.write(s)))(trustHelp());
3065
+ return EXIT.PASS;
3066
+ }
3067
+ writeErr(
3068
+ `error: unknown trust subcommand: ${sub === undefined ? "(none)" : sub} ` +
3069
+ `(expected: reconcile, inspect, corpus, value-proof, verify-seal, serve, license)\n` +
3070
+ trustHelp()
3071
+ );
3072
+ return EXIT.USAGE;
3073
+ }
3074
+
3075
+ // The in-band `vh trust` help — names the full command set (including the seal
3076
+ // commands) so the seal posture is discoverable without external docs.
3077
+ function trustHelp() {
3078
+ return [
3079
+ "vh trust — TrustLedger three-way trust-account reconciliation",
3080
+ "",
3081
+ "Subcommands:",
3082
+ " reconcile <bank> <ledger> <rentroll> [--out <dir>] [--seal [<file>]] [--license <f> --vendor <0xaddr>]",
3083
+ " run the whole pipeline -> a dated audit packet (HTML+CSV; PASS/FAIL exit).",
3084
+ " FREE: baseline-policy reconcile needs no license. PAID (require --license + --vendor):",
3085
+ " --state/--policy (multi-state policy packs) and --seal. Without a valid, vendor-pinned",
3086
+ " license carrying the matching entitlement those flags hard-error (exit 2) — see `license`.",
3087
+ " --seal [<file>] additionally writes a TAMPER-EVIDENT reconciliation seal AFTER",
3088
+ " the packet (binding the 3 source inputs + every emitted packet file, and the",
3089
+ " emitted close if --emit-close). --seal REQUIRES --out (no packet, nothing to seal).",
3090
+ " The 3 source inputs are sealed by BASENAME so the binding TRAVELS with the packet:",
3091
+ " ship each source NEXT TO the seal (same dir) and the handoff verifies anywhere.",
3092
+ " license issue|verify ...",
3093
+ " issue: mint a signed product license with a key YOU supply (read-used-discarded).",
3094
+ " verify: read-only, OFFLINE check of a license against --vendor (VALID/INVALID, 0/3).",
3095
+ " A valid license + matching --vendor unlocks reconcile's paid surfaces. `vh trust license -h`.",
3096
+ " inspect <file> --as <bank|ledger|rentroll>",
3097
+ " read-only validator/preview of ONE input file (writes nothing).",
3098
+ " corpus [--json]",
3099
+ " run the committed out-of-trust corpus and print a per-scenario table",
3100
+ " (id, control, trust-law principle, expected vs ACTUAL verdict, OK/MISMATCH)",
3101
+ " + a one-line summary. Read-only, writes nothing. Exit 0 when every scenario",
3102
+ " matches its recorded verdict, 3 on ANY mismatch (a gate regression / corpus",
3103
+ " drift). This is the one-command artifact to confirm the gate FAILs the exact",
3104
+ " frauds it claims to catch, without reading test/.",
3105
+ " value-proof <bank> <ledger> <rentroll> [--state/--policy <f> --license <f> --vendor <0xaddr>]",
3106
+ " [--prior-close <f>] [--map-file <f>] [--map <src>:<logical>=<header>] [--asserted-flagged]",
3107
+ " [--asserted-net <dollars>] [--json]",
3108
+ " run the partner's OWN already-closed period through the SAME reconcile gate and",
3109
+ " print what their manual close let through: a one-word OUTCOME, the headline, and",
3110
+ " the per-class dollar table — every number read verbatim off the gate's triage.",
3111
+ " Threads the SAME verdict-shaping inputs reconcile does: --state/--policy (license-gated,",
3112
+ " can ESCALATE a finding and FLIP the verdict), --prior-close (continuity), and",
3113
+ " --map-file/--map (non-default headers). The reported gate verdict + exit code EQUAL the",
3114
+ " production gate's for the SAME inputs — value-proof never claims clean on a period the",
3115
+ " licensed gate FAILs. Read-only, writes nothing. Exit 0 clean_confirmed / 3 out_of_trust_missed",
3116
+ " (a missed shortage) / 4 data_gap_only (fix-and-rerun, distinct from a real FAIL) / 2 usage / 1 IO.",
3117
+ " serve [--port <n>] [--host <h>]",
3118
+ " launch the LOCAL web front-door (default http://127.0.0.1:4173/) so a broker can drop",
3119
+ " the three files in a browser and watch the balances tie out. Files are processed IN",
3120
+ " MEMORY; NOTHING is written to disk server-side. Binds to localhost — exposing it (behind",
3121
+ " YOUR nginx/Cloudflare on YOUR domain with TLS) is a HUMAN deploy step, never auto-deployed.",
3122
+ " verify-seal <sealfile> [--dir <d>] [--inputs <d>] [--json]",
3123
+ " read-only, OFFLINE (NO key, NO network): re-derive each sealed file from disk and",
3124
+ " print ACCEPTED (0) only when EVERY file matches; else REJECTED (3) with the precise",
3125
+ " per-file CHANGED/MISSING/UNEXPECTED list. Output files resolve relative to the seal's",
3126
+ " directory (or --dir); the source inputs resolve there too (or --inputs <d>) since they",
3127
+ " are sealed by basename. A seal is TAMPER-EVIDENT, NOT a trusted timestamp; CPA review governs.",
3128
+ "",
3129
+ "Exit: 0 ok / 3 gate FAIL (does-not-tie-out or REJECTED) / 2 usage / 1 IO.",
3130
+ "",
3131
+ ].join("\n");
3132
+ }
3133
+
3134
+ module.exports = {
3135
+ EXIT,
3136
+ parseReconcileArgs,
3137
+ runReconcile,
3138
+ cmdReconcile,
3139
+ parseInspectArgs,
3140
+ runInspect,
3141
+ cmdInspect,
3142
+ renderInspect,
3143
+ inspectHint,
3144
+ parseCorpusArgs,
3145
+ runCorpusCmd,
3146
+ cmdCorpus,
3147
+ renderCorpus,
3148
+ corpusSummaryLine,
3149
+ CORPUS_CAVEAT,
3150
+ parseValueProofArgs,
3151
+ runValueProof,
3152
+ cmdValueProof,
3153
+ renderValueProof,
3154
+ VALUE_PROOF_CAVEAT,
3155
+ VALUE_PROOF_EXIT,
3156
+ parseVerifySealArgs,
3157
+ runVerifySeal,
3158
+ cmdVerifySeal,
3159
+ renderVerifySeal,
3160
+ parseServeArgs,
3161
+ runServe,
3162
+ cmdServe,
3163
+ SERVE_DEFAULT_PORT,
3164
+ SERVE_DEFAULT_HOST,
3165
+ trustHelp,
3166
+ cmdTrust,
3167
+ parseLicenseIssueArgs,
3168
+ cmdLicenseIssue,
3169
+ parseLicenseFulfillArgs,
3170
+ cmdLicenseFulfill,
3171
+ parseLicenseVerifyArgs,
3172
+ cmdLicenseVerify,
3173
+ cmdLicense,
3174
+ licenseHelp,
3175
+ gateReconcile,
3176
+ PAID_FEATURE_ENTITLEMENTS,
3177
+ nowISO,
3178
+ todayISO,
3179
+ };