verifyhash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- package/trustledger/valueproof.js +350 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1afa0e08c9624505ad967ab88c91553c6af00b0e5e0f2b790ddf315741ed1742 trustledger-standalone.html
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// TrustLedger — door-core.js
|
|
4
|
+
//
|
|
5
|
+
// T-65.2: the PURE payload→result core of the web door, factored out of server.js
|
|
6
|
+
// so that BOTH surfaces call the SAME functions and can never drift:
|
|
7
|
+
// * trustledger/server.js (the HTTP door) routes /api/reconcile and /api/inspect
|
|
8
|
+
// straight into reconcilePayload / inspectPayload below, exactly as before —
|
|
9
|
+
// behavior, error names, statuses, and bytes UNCHANGED by the factoring;
|
|
10
|
+
// * trustledger/build-standalone.js inlines THIS FILE VERBATIM into the offline
|
|
11
|
+
// single-file app (trustledger/dist/trustledger-standalone.html), where the
|
|
12
|
+
// page's transport seams call the same two functions directly in-page.
|
|
13
|
+
//
|
|
14
|
+
// EVERYTHING here is PURE and I/O-free: no fs, no http, no clock (the caller
|
|
15
|
+
// injects `reportDate`), no writes. The ONLY module dependencies are the engine
|
|
16
|
+
// modules (ingest / report / policy / close) and the license verifier — all
|
|
17
|
+
// required at top level with plain relative specifiers so the standalone bundler
|
|
18
|
+
// can rewrite them against its own module registry (the license module is swapped
|
|
19
|
+
// there for a FAIL-CLOSED offline shim; the gate below is inlined verbatim and is
|
|
20
|
+
// therefore REUSED, never re-implemented and never weakened).
|
|
21
|
+
//
|
|
22
|
+
// The prose below is moved verbatim from server.js (T-27.1 / T-28.1 / T-28.2 /
|
|
23
|
+
// T-29.3) — the contracts are unchanged; only the file they live in moved.
|
|
24
|
+
|
|
25
|
+
const ingest = require("./ingest");
|
|
26
|
+
const report = require("./report");
|
|
27
|
+
const policy = require("./policy");
|
|
28
|
+
const close = require("./close");
|
|
29
|
+
const license = require("./license");
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// A named, HTTP-status-bearing error for the request layer. Carries a stable
|
|
33
|
+
// machine `error` code (snake_case) plus the human `message`, so the JSON body
|
|
34
|
+
// is { error, message } — a named error, never a stack trace.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
class HttpError extends Error {
|
|
38
|
+
constructor(status, code, message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "HttpError";
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Map an engine error to a stable snake_case machine code. The engine's own
|
|
47
|
+
// named errors (IngestError / ReportError / PolicyError / CloseError) carry the
|
|
48
|
+
// precise, already-located human message; we only need a coarse machine label.
|
|
49
|
+
function engineErrorCode(err) {
|
|
50
|
+
switch (err && err.name) {
|
|
51
|
+
case "IngestError":
|
|
52
|
+
return "ingest_error";
|
|
53
|
+
case "ReportError":
|
|
54
|
+
return "report_error";
|
|
55
|
+
case "PolicyError":
|
|
56
|
+
return "policy_error";
|
|
57
|
+
case "CloseError":
|
|
58
|
+
return "close_error";
|
|
59
|
+
default:
|
|
60
|
+
return "reconcile_error";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// T-29.3: the WEB door's LICENSE GATE — the SAME gate the CLI `gateReconcile`
|
|
66
|
+
// applies, threaded through HTTP. A request that asks for a PAID surface
|
|
67
|
+
// (`state`/`policy` => multi-state policy packs, or `seal` => the reconciliation
|
|
68
|
+
// seal) WITHOUT a valid, vendor-pinned license is REFUSED with a NAMED 4xx; the
|
|
69
|
+
// FREE inspect + baseline reconcile routes stay open and behave byte-for-byte as
|
|
70
|
+
// before. The server holds NO key and verifies OFFLINE: `verifyLicense` needs only
|
|
71
|
+
// the pinned vendor ADDRESS + the supplied container (no network, no signing key).
|
|
72
|
+
//
|
|
73
|
+
// The flag->entitlement mapping is the SAME contract the CLI uses (so the two gates
|
|
74
|
+
// can never drift): `state`/`policy` need `multi_state_policy`; `seal` needs `seal`.
|
|
75
|
+
// A refusal carries the PRECISE reason verifyLicense returns (wrong_issuer /
|
|
76
|
+
// expired / not_yet_valid / bad_signature / malformed), so a wrong/expired license
|
|
77
|
+
// NEVER silently downgrades to a free run — it is reported, not ignored.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
// Which entitlement each paid WEB surface requires. Mirrors the CLI's
|
|
81
|
+
// PAID_FEATURE_ENTITLEMENTS exactly (state/policy => multi_state_policy; seal =>
|
|
82
|
+
// seal), keyed off the request payload's own field names.
|
|
83
|
+
const WEB_PAID_FEATURE_ENTITLEMENTS = Object.freeze([
|
|
84
|
+
{
|
|
85
|
+
requested: (p) =>
|
|
86
|
+
(p.state != null && String(p.state).trim() !== "") ||
|
|
87
|
+
(p.policy != null && String(p.policy).trim() !== ""),
|
|
88
|
+
entitlement: "multi_state_policy",
|
|
89
|
+
label: "multi-state policy packs (state)",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
requested: (p) => p.seal === true,
|
|
93
|
+
entitlement: "seal",
|
|
94
|
+
label: "the tamper-evident reconciliation seal (seal)",
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// Apply the license gate to an already-parsed request payload. Returns silently
|
|
99
|
+
// when the request is permitted (free tier, or a valid license covering every
|
|
100
|
+
// requested paid feature). Throws a NAMED HttpError otherwise:
|
|
101
|
+
// * license_required (402) — a paid surface was requested with NO license at all
|
|
102
|
+
// * license_invalid (403) — a license WAS supplied but is invalid for this run
|
|
103
|
+
// (malformed / bad_signature / wrong_issuer / expired / not_yet_valid, or a
|
|
104
|
+
// valid license that does not carry the required entitlement)
|
|
105
|
+
// `now` is the injected report date so the verdict is deterministic. The license
|
|
106
|
+
// container + vendorAddress come from the request body; the server holds no key.
|
|
107
|
+
function gatePayload(payload, now) {
|
|
108
|
+
const needed = WEB_PAID_FEATURE_ENTITLEMENTS.filter((f) => f.requested(payload));
|
|
109
|
+
if (needed.length === 0) {
|
|
110
|
+
// FREE TIER: no paid surface requested — proceed unchanged. A stray
|
|
111
|
+
// license/vendorAddress with no paid feature costs nothing and is ignored.
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const featureList = needed.map((f) => f.label).join(" and ");
|
|
116
|
+
const hasLicense = payload.license != null;
|
|
117
|
+
const hasVendor = payload.vendorAddress != null;
|
|
118
|
+
|
|
119
|
+
// Both must travel together: a license is worthless without the vendor address
|
|
120
|
+
// to PIN it to, and a vendor with no license is nothing to verify.
|
|
121
|
+
if (!hasLicense && !hasVendor) {
|
|
122
|
+
throw new HttpError(
|
|
123
|
+
402,
|
|
124
|
+
"license_required",
|
|
125
|
+
`${featureList} ${needed.length > 1 ? "are" : "is"} a paid feature and requires a license; ` +
|
|
126
|
+
"supply { license, vendorAddress } in the request body. " +
|
|
127
|
+
"The free tier — baseline-policy reconcile + file inspect — needs no license."
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (!hasLicense || !hasVendor) {
|
|
131
|
+
throw new HttpError(
|
|
132
|
+
402,
|
|
133
|
+
"license_required",
|
|
134
|
+
'both "license" and "vendorAddress" must be supplied together (a license is verified by ' +
|
|
135
|
+
"pinning it to the vendor address)"
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Parse + validate the supplied container (a JSON string OR an already-parsed
|
|
140
|
+
// object). A malformed container is a license_invalid 403 — never half-trusted.
|
|
141
|
+
let container;
|
|
142
|
+
try {
|
|
143
|
+
container = license.readLicense(payload.license);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
throw new HttpError(
|
|
146
|
+
403,
|
|
147
|
+
"license_invalid",
|
|
148
|
+
`the supplied license is not a valid signed license container: ${e.message}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Verify OFFLINE against the pinned vendor, dated at the run's reportDate. A
|
|
153
|
+
// malformed vendorAddress is a request error (the address could not be parsed).
|
|
154
|
+
let verdict;
|
|
155
|
+
try {
|
|
156
|
+
verdict = license.verifyLicense(container, { now, vendorAddress: payload.vendorAddress });
|
|
157
|
+
} catch (e) {
|
|
158
|
+
// verifyLicense throws only for a garbage vendorAddress / now — a request bug.
|
|
159
|
+
throw new HttpError(400, "bad_request", e.message);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!verdict.valid) {
|
|
163
|
+
// Report the PRECISE reason — never silently downgrade to a free run.
|
|
164
|
+
throw new HttpError(
|
|
165
|
+
403,
|
|
166
|
+
"license_invalid",
|
|
167
|
+
`${featureList} requires a valid license, but the supplied license is invalid ` +
|
|
168
|
+
`(reason: ${verdict.reason}); the free baseline reconcile remains available without state/policy/seal.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Valid + in-window + correct issuer. Require EACH requested feature's
|
|
173
|
+
// entitlement to actually be granted — a license never grants what it was not sold.
|
|
174
|
+
for (const f of needed) {
|
|
175
|
+
if (!license.hasEntitlement(verdict, f.entitlement)) {
|
|
176
|
+
throw new HttpError(
|
|
177
|
+
403,
|
|
178
|
+
"license_invalid",
|
|
179
|
+
`the supplied license is valid but does not include the "${f.entitlement}" entitlement ` +
|
|
180
|
+
`needed for ${f.label}; this license grants only [${verdict.entitlements.join(", ")}]. ` +
|
|
181
|
+
"The free baseline reconcile remains available."
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// The pure core: take the already-parsed request payload (strings + optional
|
|
189
|
+
// state/priorClose) and produce the response shape. NO I/O. Throws HttpError on
|
|
190
|
+
// any bad input (a malformed file, an unknown state, a bad prior-close) so the
|
|
191
|
+
// caller maps it straight to an HTTP status + named JSON body. `reportDate` is
|
|
192
|
+
// injected (the caller supplies today) so this function stays deterministic.
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
// Read the OPTIONAL per-file `maps` object (T-28.2). Returns a frozen
|
|
196
|
+
// { bank, ledger, rentroll } where each entry is either a plain object (the
|
|
197
|
+
// `columnMap` to thread into that file's strict parser) or `undefined` (so the
|
|
198
|
+
// parser is called with `columnMap: undefined` — the byte-identical no-map path).
|
|
199
|
+
// STRICT on shape: `maps` (when present) must be a plain object, and each named
|
|
200
|
+
// per-file entry (when present) must be a plain object — anything else is a named
|
|
201
|
+
// 400, never a coercion. Unknown keys inside `maps` are ignored (only the three
|
|
202
|
+
// file keys are honoured), and the deep validity of each map (unknown logical
|
|
203
|
+
// field, or a header absent from the file) is left to the strict parser, which
|
|
204
|
+
// raises the SAME located IngestError it always would.
|
|
205
|
+
function readOptionalMaps(raw) {
|
|
206
|
+
const empty = Object.freeze({ bank: undefined, ledger: undefined, rentroll: undefined });
|
|
207
|
+
if (raw == null) return empty;
|
|
208
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
209
|
+
throw new HttpError(
|
|
210
|
+
400,
|
|
211
|
+
"invalid_maps",
|
|
212
|
+
'"maps" must be an object of { bank?, ledger?, rentroll? } column maps'
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const key of ["bank", "ledger", "rentroll"]) {
|
|
217
|
+
const m = raw[key];
|
|
218
|
+
if (m == null) {
|
|
219
|
+
out[key] = undefined;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (typeof m !== "object" || Array.isArray(m)) {
|
|
223
|
+
throw new HttpError(
|
|
224
|
+
400,
|
|
225
|
+
"invalid_maps",
|
|
226
|
+
`"maps.${key}" must be an object of { <logicalField>: <headerName> }`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
out[key] = m;
|
|
230
|
+
}
|
|
231
|
+
return Object.freeze(out);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function reconcilePayload(payload, reportDate) {
|
|
235
|
+
if (payload == null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
236
|
+
throw new HttpError(400, "bad_request", "request body must be a JSON object");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The three file CONTENTS are REQUIRED and must be strings. A missing or
|
|
240
|
+
// non-string field is a named 400 — never a coercion of, say, a number to "".
|
|
241
|
+
for (const key of ["bank", "ledger", "rentroll"]) {
|
|
242
|
+
if (typeof payload[key] !== "string") {
|
|
243
|
+
throw new HttpError(
|
|
244
|
+
400,
|
|
245
|
+
"missing_file",
|
|
246
|
+
`"${key}" is required and must be the file contents as a text string`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// LICENSE GATE (T-29.3). Apply the SAME paid-surface gate the CLI uses BEFORE any
|
|
252
|
+
// paid feature is resolved, so a gated request (state/policy/seal) without a valid
|
|
253
|
+
// license is refused with a NAMED license_required/license_invalid — never folded
|
|
254
|
+
// into a downstream policy_error and never silently downgraded to a free run.
|
|
255
|
+
gatePayload(payload, reportDate);
|
|
256
|
+
|
|
257
|
+
// Optional per-state policy. An unknown code is a named 400 (PolicyError's
|
|
258
|
+
// message names the available codes), not a silent fall-through to baseline.
|
|
259
|
+
let activePolicy = null;
|
|
260
|
+
if (payload.state != null && String(payload.state).trim() !== "") {
|
|
261
|
+
try {
|
|
262
|
+
activePolicy = policy.resolveState(String(payload.state));
|
|
263
|
+
} catch (e) {
|
|
264
|
+
throw new HttpError(400, "policy_error", e.message);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Optional roll-forward from a prior period's close artifact (its JSON TEXT).
|
|
269
|
+
// Seeds this run's opening balances; a malformed close is a named 400.
|
|
270
|
+
let priorClose = null;
|
|
271
|
+
let opening = { bank: 0, book: 0 };
|
|
272
|
+
if (payload.priorClose != null && String(payload.priorClose).trim() !== "") {
|
|
273
|
+
try {
|
|
274
|
+
priorClose = close.readClose(String(payload.priorClose));
|
|
275
|
+
} catch (e) {
|
|
276
|
+
throw new HttpError(400, "close_error", e.message);
|
|
277
|
+
}
|
|
278
|
+
opening = { bank: priorClose.ending.bank, book: priorClose.ending.book };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Optional per-file column maps (T-28.2). A mapping the broker fixed in the
|
|
282
|
+
// inspect flow is threaded back here so the REAL run honours it — the same
|
|
283
|
+
// `{ <logicalField>: <headerName> }` shape the strict parsers' `columnMap`
|
|
284
|
+
// already accepts, keyed by the SAME three file keys. When `maps` is absent (or
|
|
285
|
+
// a per-file map is absent) behaviour is BYTE-FOR-BYTE the no-map path: each
|
|
286
|
+
// parser is called with `columnMap: undefined`, exactly as before. A bad shape
|
|
287
|
+
// (not a plain object, or a per-file entry that is not a plain object) is a
|
|
288
|
+
// named 400 — never a silent coercion.
|
|
289
|
+
const maps = readOptionalMaps(payload.maps);
|
|
290
|
+
|
|
291
|
+
// Ingest the three files (STRICT — the first malformed row raises a located
|
|
292
|
+
// IngestError, which we surface as a named 400 rather than dropping the row).
|
|
293
|
+
let bank;
|
|
294
|
+
let book;
|
|
295
|
+
let rentroll;
|
|
296
|
+
try {
|
|
297
|
+
bank = ingest.parseBankStatement(payload.bank, { columnMap: maps.bank });
|
|
298
|
+
book = ingest.parseQuickBooksCSV(payload.ledger, { columnMap: maps.ledger });
|
|
299
|
+
rentroll = ingest.parseRentRollCSV(payload.rentroll, { columnMap: maps.rentroll });
|
|
300
|
+
} catch (e) {
|
|
301
|
+
throw new HttpError(400, engineErrorCode(e), e.message);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Build the deterministic packet model (match + reconcile + report inside),
|
|
305
|
+
// then render the SAME HTML + CSV artifacts the CLI emits — but keep them in
|
|
306
|
+
// memory and return them in the JSON response. Nothing is written to disk.
|
|
307
|
+
let model;
|
|
308
|
+
let reportHtml;
|
|
309
|
+
let reportCsv;
|
|
310
|
+
try {
|
|
311
|
+
model = report.buildPacket({
|
|
312
|
+
bank,
|
|
313
|
+
book,
|
|
314
|
+
rentroll,
|
|
315
|
+
reportDate,
|
|
316
|
+
opening,
|
|
317
|
+
policy: activePolicy,
|
|
318
|
+
priorClose,
|
|
319
|
+
});
|
|
320
|
+
reportHtml = report.renderHTML(model);
|
|
321
|
+
reportCsv = report.renderExceptionsCSV(model);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
throw new HttpError(400, engineErrorCode(e), e.message);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
tiesOut: model.tiesOut,
|
|
328
|
+
pass: model.pass,
|
|
329
|
+
balances: model.balances,
|
|
330
|
+
exceptions: model.exceptions,
|
|
331
|
+
summary: report.summaryLine(model),
|
|
332
|
+
reportHtml,
|
|
333
|
+
reportCsv,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// T-28.1: the read-only DIAGNOSTIC core. Maps a broker-facing `source` spelling
|
|
339
|
+
// (the SAME file keys /api/reconcile uses, plus the `quickbooks` synonym) to the
|
|
340
|
+
// engine's SOURCE.*, then calls ingest.diagnoseSource VERBATIM and returns its
|
|
341
|
+
// report shape. UNLIKE reconcilePayload this does NOT fail closed: a well-formed
|
|
342
|
+
// file with unmatched columns returns 200 with `requiredMissing` populated — that
|
|
343
|
+
// is a self-service finding the UI renders, not a server error. It throws an
|
|
344
|
+
// HttpError 400 ONLY for a request that is itself malformed: an unknown source, a
|
|
345
|
+
// missing/non-string `text`, or a malformed `columnMap` (the SAME named IngestError
|
|
346
|
+
// the strict parser/indexHeader gives, raised EARLY via validateColumnMapForSource
|
|
347
|
+
// so a bad map is a 400 rather than being folded into the diagnose error list).
|
|
348
|
+
// PURE / I-O-free, exactly like reconcilePayload — the server never writes to disk.
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
// Broker-facing `source` spelling -> engine SOURCE.*. Accepts the SAME keys
|
|
352
|
+
// /api/reconcile uses for its three files (bank / ledger / rentroll) plus the
|
|
353
|
+
// natural `quickbooks` / `rent_roll` synonyms, so the door names sources the way
|
|
354
|
+
// the CLI's `--as` does without forcing the browser to know the engine's enum.
|
|
355
|
+
const INSPECT_SOURCE = Object.freeze({
|
|
356
|
+
bank: ingest.SOURCE.BANK,
|
|
357
|
+
ledger: ingest.SOURCE.QUICKBOOKS,
|
|
358
|
+
quickbooks: ingest.SOURCE.QUICKBOOKS,
|
|
359
|
+
rentroll: ingest.SOURCE.RENT_ROLL,
|
|
360
|
+
rent_roll: ingest.SOURCE.RENT_ROLL,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
function inspectPayload(payload) {
|
|
364
|
+
if (payload == null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
365
|
+
throw new HttpError(400, "bad_request", "request body must be a JSON object");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The `source` selects which of the three logical types to diagnose. An unknown
|
|
369
|
+
// (or missing) spelling is a named 400 that NAMES the accepted spellings, so the
|
|
370
|
+
// caller can self-correct without reading source.
|
|
371
|
+
const source = INSPECT_SOURCE[String(payload.source)];
|
|
372
|
+
if (!source) {
|
|
373
|
+
throw new HttpError(
|
|
374
|
+
400,
|
|
375
|
+
"unknown_source",
|
|
376
|
+
`"source" must be one of: ${Object.keys(INSPECT_SOURCE).join(", ")}`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// The file CONTENTS are REQUIRED and must be a string — a missing or non-string
|
|
381
|
+
// `text` is a named 400, never a coercion (diagnoseSource treats a null text as
|
|
382
|
+
// a file-level "no input" finding, but over the door an absent body field is a
|
|
383
|
+
// client mistake, so we reject it up front with a clear message).
|
|
384
|
+
if (typeof payload.text !== "string") {
|
|
385
|
+
throw new HttpError(
|
|
386
|
+
400,
|
|
387
|
+
"missing_text",
|
|
388
|
+
`"text" is required and must be the file contents as a text string`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Optional column-map override. Validate it EARLY against this file's real
|
|
393
|
+
// header so a malformed map (unknown logical key, or a header absent from the
|
|
394
|
+
// file) is a named 400 with the SAME IngestError message the strict parser gives
|
|
395
|
+
// — rather than being folded into the diagnose report's error list. A bad shape
|
|
396
|
+
// (not a plain object) is likewise a named 400.
|
|
397
|
+
let columnMap = null;
|
|
398
|
+
if (payload.columnMap != null) {
|
|
399
|
+
if (typeof payload.columnMap !== "object" || Array.isArray(payload.columnMap)) {
|
|
400
|
+
throw new HttpError(
|
|
401
|
+
400,
|
|
402
|
+
"invalid_column_map",
|
|
403
|
+
'"columnMap" must be an object of { <logicalField>: <headerName> }'
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
columnMap = payload.columnMap;
|
|
407
|
+
try {
|
|
408
|
+
ingest.validateColumnMapForSource(source, payload.text, columnMap);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
throw new HttpError(400, engineErrorCode(e), e.message);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Call the EXISTING diagnostic VERBATIM (no re-implementation of parsing) and
|
|
415
|
+
// return EXACTLY the diagnose report shape the CLI `vh trust inspect` consumes.
|
|
416
|
+
// diagnoseSource is pure and only throws on an unknown source (already guarded);
|
|
417
|
+
// every file/row problem is reported in the structure, not thrown.
|
|
418
|
+
const rep = ingest.diagnoseSource(source, payload.text, { columnMap });
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
source: rep.source,
|
|
422
|
+
format: rep.format,
|
|
423
|
+
header: rep.header,
|
|
424
|
+
mapped: rep.mapped,
|
|
425
|
+
requiredMissing: rep.requiredMissing,
|
|
426
|
+
rowCount: rep.rowCount,
|
|
427
|
+
okCount: rep.okCount,
|
|
428
|
+
sample: rep.sample,
|
|
429
|
+
errors: rep.errors,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
module.exports = {
|
|
434
|
+
HttpError,
|
|
435
|
+
engineErrorCode,
|
|
436
|
+
WEB_PAID_FEATURE_ENTITLEMENTS,
|
|
437
|
+
gatePayload,
|
|
438
|
+
readOptionalMaps,
|
|
439
|
+
reconcilePayload,
|
|
440
|
+
INSPECT_SOURCE,
|
|
441
|
+
inspectPayload,
|
|
442
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Date,Description,Debit,Credit,Type
|
|
2
|
+
2026-05-01,"Deposit - rent received",,1500.00,Deposit
|
|
3
|
+
2026-05-03,"Check #1042 owner draw - Smith",750.00,,Check
|
|
4
|
+
2026-05-05,"NSF returned item - Jones rent reversal",1200.00,,NSF
|
|
5
|
+
2026-05-07,"Partial deposit - Doe rent (1 of 2)",,650.00,Deposit
|
|
6
|
+
2026-05-07,"Partial deposit - Doe rent (2 of 2)",,650.00,Deposit
|
|
7
|
+
2026-05-09,"Monthly service charge",12.50,,Fee
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
When,Narrative,MoneyOut,MoneyIn,Kategorie
|
|
2
|
+
2026-05-01,"Deposit - rent received",,1500.00,Deposit
|
|
3
|
+
2026-05-03,"Check #1042 owner draw - Smith",750.00,,Check
|
|
4
|
+
2026-05-05,"NSF returned item - Jones rent reversal",1200.00,,NSF
|
|
5
|
+
2026-05-09,"Monthly service charge",12.50,,Fee
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
OFXHEADER:100
|
|
2
|
+
DATA:OFXSGML
|
|
3
|
+
VERSION:102
|
|
4
|
+
|
|
5
|
+
<OFX>
|
|
6
|
+
<BANKMSGSRSV1>
|
|
7
|
+
<STMTTRNRS>
|
|
8
|
+
<STMTRS>
|
|
9
|
+
<BANKTRANLIST>
|
|
10
|
+
<STMTTRN>
|
|
11
|
+
<TRNTYPE>CREDIT
|
|
12
|
+
<DTPOSTED>20260501120000
|
|
13
|
+
<TRNAMT>1500.00
|
|
14
|
+
<NAME>Deposit - rent received
|
|
15
|
+
<MEMO>Deposit - rent received
|
|
16
|
+
</STMTTRN>
|
|
17
|
+
<STMTTRN>
|
|
18
|
+
<TRNTYPE>CHECK
|
|
19
|
+
<DTPOSTED>20260503
|
|
20
|
+
<TRNAMT>-750.00
|
|
21
|
+
<NAME>Owner draw
|
|
22
|
+
<MEMO>Check #1042 owner draw - Smith
|
|
23
|
+
</STMTTRN>
|
|
24
|
+
<STMTTRN>
|
|
25
|
+
<TRNTYPE>DEBIT
|
|
26
|
+
<DTPOSTED>20260505
|
|
27
|
+
<TRNAMT>-1200.00
|
|
28
|
+
<MEMO>NSF returned item - Jones rent reversal
|
|
29
|
+
</STMTTRN>
|
|
30
|
+
</BANKTRANLIST>
|
|
31
|
+
</STMTRS>
|
|
32
|
+
</STMTTRNRS>
|
|
33
|
+
</BANKMSGSRSV1>
|
|
34
|
+
</OFX>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Posting Date,Description,Withdrawal Amt,Deposit Amt,Running Balance,Check Number
|
|
2
|
+
"Jan 5, 2024","Deposit - rent received",,1500.00,1500.00,
|
|
3
|
+
"Jan 7, 2024","Check owner draw - Smith",750.00,,750.00,1042
|
|
4
|
+
"Jan 9, 2024","NSF returned item - Jones rent reversal",1200.00,,-450.00,
|
|
5
|
+
"Jan 11, 2024","Monthly service charge",12.50,,-462.50,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "trustledger.period-close/v1",
|
|
3
|
+
"period": "2026-04",
|
|
4
|
+
"reportDate": "2026-04-30",
|
|
5
|
+
"opening": {
|
|
6
|
+
"bank": 0,
|
|
7
|
+
"book": 0
|
|
8
|
+
},
|
|
9
|
+
"ending": {
|
|
10
|
+
"bank": 250000,
|
|
11
|
+
"book": 250000
|
|
12
|
+
},
|
|
13
|
+
"subledger": 250000,
|
|
14
|
+
"tiesOut": true,
|
|
15
|
+
"pass": true,
|
|
16
|
+
"inputs": {
|
|
17
|
+
"bankRecords": 1,
|
|
18
|
+
"bookRecords": 1,
|
|
19
|
+
"rentrollRecords": 1
|
|
20
|
+
},
|
|
21
|
+
"inputsDigest": "05f5b527084ac007b60f8117f7bf188ebcdc60fde8e20f6c934c1f33b6e8f166"
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reportDate": "2026-05-31",
|
|
3
|
+
"period": "2026-05",
|
|
4
|
+
"opening": { "bank": 50000, "book": 50000 },
|
|
5
|
+
"bank": [
|
|
6
|
+
{ "date": "2026-05-02", "amount": 150000, "memo": "deposit smith", "kind": "deposit", "party": "", "source": "bank" }
|
|
7
|
+
],
|
|
8
|
+
"book": [
|
|
9
|
+
{ "date": "2026-05-01", "amount": 150000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" }
|
|
10
|
+
],
|
|
11
|
+
"rentroll": [
|
|
12
|
+
{ "date": "2026-05-01", "amount": 200000, "memo": "rent", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" }
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "bank-book-mismatch--benign-twin",
|
|
3
|
+
"control": "bank_book_mismatch",
|
|
4
|
+
"expectedVerdict": "PASS",
|
|
5
|
+
"expectedFinding": "bank_book_mismatch",
|
|
6
|
+
"principle": "The benign near-twin of the bank/book control: the SAME matched activity and the SAME $2,000 of book + sub-ledger, but now the bank opened at the SAME $500 as the book. The adjusted bank balance equals the book exactly, the three balances tie out, the engine raises NO bank_book_mismatch finding, and the packet PASSes. The only difference from the out-of-trust twin is the bank opening: $500 vs $0."
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reportDate": "2026-05-31",
|
|
3
|
+
"period": "2026-05",
|
|
4
|
+
"opening": { "bank": 0, "book": 50000 },
|
|
5
|
+
"bank": [
|
|
6
|
+
{ "date": "2026-05-02", "amount": 150000, "memo": "deposit smith", "kind": "deposit", "party": "", "source": "bank" }
|
|
7
|
+
],
|
|
8
|
+
"book": [
|
|
9
|
+
{ "date": "2026-05-01", "amount": 150000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" }
|
|
10
|
+
],
|
|
11
|
+
"rentroll": [
|
|
12
|
+
{ "date": "2026-05-01", "amount": 200000, "memo": "rent", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" }
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "bank-book-mismatch--out-of-trust",
|
|
3
|
+
"control": "bank_book_mismatch",
|
|
4
|
+
"expectedVerdict": "FAIL",
|
|
5
|
+
"expectedFinding": "bank_book_mismatch",
|
|
6
|
+
"principle": "After outstanding/in-transit items, the adjusted bank balance must equal the book balance. Here the activity lines match (no timing items), but the bank opened $500 SHORT of the book: the books say the trust account should hold $2,000 while the bank holds only $1,500. The bank is short of cash relative to the records — a genuine shortage, the textbook out-of-trust case (money that should be in the account is not there). The engine raises an error-severity bank_book_mismatch finding for the -$500 gap and the packet FAILs."
|
|
7
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reportDate": "2026-05-31",
|
|
3
|
+
"period": "2026-05",
|
|
4
|
+
"opening": { "bank": 250000, "book": 250000 },
|
|
5
|
+
"priorClosePath": "_shared/prior-close.json",
|
|
6
|
+
"bank": [
|
|
7
|
+
{ "date": "2026-05-02", "amount": 150000, "memo": "deposit smith", "kind": "deposit", "party": "", "source": "bank" }
|
|
8
|
+
],
|
|
9
|
+
"book": [
|
|
10
|
+
{ "date": "2026-05-01", "amount": 150000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" }
|
|
11
|
+
],
|
|
12
|
+
"rentroll": [
|
|
13
|
+
{ "date": "2026-05-01", "amount": 400000, "memo": "rent carried", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "continuity-break--benign-twin",
|
|
3
|
+
"control": "continuity_break",
|
|
4
|
+
"expectedVerdict": "PASS",
|
|
5
|
+
"expectedFinding": "continuity_break",
|
|
6
|
+
"principle": "The benign near-twin of the continuity control: the SAME prior close (_shared/prior-close.json, ending $2,500 / $2,500), but this period opens EXACTLY where the prior period closed — $2,500 bank / $2,500 book. The roll-forward is clean, the chain of custody is unbroken, the three balances tie out ($4,000 across all legs), the engine raises NO continuity_break finding, and the packet PASSes. Two fields differ from the out-of-trust twin, and ONLY the first is the variable under test: (1) the opening balances go from $0 to $2,500 — this is the continuity fix; (2) the carried sub-ledger (rent-roll) line goes from $1,500 to $4,000 (150000 to 400000 cents). The second change is NOT free — it is forced by trust math: once $2,500 of beneficiary money is carried forward into the opening, the book balance becomes $4,000, so the beneficiary sub-ledger must also carry $4,000 or the three legs would no longer tie and the engine would (correctly) FAIL on subledger_out_of_balance instead. Carrying the sub-ledger forward keeps every other control satisfied, so the continuity control is the only thing this pair varies."
|
|
7
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reportDate": "2026-05-31",
|
|
3
|
+
"period": "2026-05",
|
|
4
|
+
"opening": { "bank": 0, "book": 0 },
|
|
5
|
+
"priorClosePath": "_shared/prior-close.json",
|
|
6
|
+
"bank": [
|
|
7
|
+
{ "date": "2026-05-02", "amount": 150000, "memo": "deposit smith", "kind": "deposit", "party": "", "source": "bank" }
|
|
8
|
+
],
|
|
9
|
+
"book": [
|
|
10
|
+
{ "date": "2026-05-01", "amount": 150000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" }
|
|
11
|
+
],
|
|
12
|
+
"rentroll": [
|
|
13
|
+
{ "date": "2026-05-01", "amount": 150000, "memo": "rent", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "continuity-break--out-of-trust",
|
|
3
|
+
"control": "continuity_break",
|
|
4
|
+
"expectedVerdict": "FAIL",
|
|
5
|
+
"expectedFinding": "continuity_break",
|
|
6
|
+
"principle": "The trust chain requires that each period's OPENING balances equal the prior period's signed ENDING balances, to the penny. The prior close (_shared/prior-close.json) ended at $2,500 bank / $2,500 book, but this period opens at $0 / $0. A non-zero roll-forward gap means a period was skipped, edited, or re-keyed and the chain of custody over the trust money is broken. The engine raises an error-severity continuity_break finding for the -$2,500 gap and the packet FAILs."
|
|
7
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reportDate": "2026-05-31",
|
|
3
|
+
"period": "2026-05",
|
|
4
|
+
"bank": [],
|
|
5
|
+
"book": [
|
|
6
|
+
{ "date": "2026-05-01", "amount": 50000, "memo": "rent smith", "kind": "deposit", "party": "Smith (4A)", "source": "quickbooks" },
|
|
7
|
+
{ "date": "2026-05-01", "amount": 50000, "memo": "rent jones", "kind": "deposit", "party": "Jones (4B)", "source": "quickbooks" }
|
|
8
|
+
],
|
|
9
|
+
"rentroll": [
|
|
10
|
+
{ "date": "2026-05-01", "amount": 50000, "memo": "rent smith", "kind": "rent", "party": "Smith (4A)", "source": "rentroll" },
|
|
11
|
+
{ "date": "2026-05-01", "amount": 50000, "memo": "rent jones", "kind": "rent", "party": "Jones (4B)", "source": "rentroll" }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "negative-tenant-ledger--benign-twin",
|
|
3
|
+
"control": "negative_tenant_ledger",
|
|
4
|
+
"expectedVerdict": "PASS",
|
|
5
|
+
"expectedFinding": "negative_tenant_ledger",
|
|
6
|
+
"principle": "The benign near-twin of the negative-ledger control. It shares the SAME bank (empty), the SAME book ($500 deposit each for Smith (4A) and Jones (4B), book balance $1,000), and the SAME two beneficiaries as the out-of-trust twin. The ONLY difference is the two rent-roll sub-ledger lines: instead of crediting $1,500 to Smith and -$500 to Jones (Jones robbed to cover Smith), each tenant is simply credited their own $500. Every individual ledger is now non-negative (Smith +$500, Jones +$500), the pooled sub-ledger sum ($1,000) still ties to the $1,000 book exactly as before, no beneficiary is short, the engine raises NO negative_tenant_ledger finding, and the packet PASSes. NOTE: the separate control-account-exclusion rule (a 'Owner ...'-prefixed account is allowed to go negative without being flagged) is exercised by the owner-overdraw control's own fixtures, not here, so this pair varies only the one beneficiary's sign."
|
|
7
|
+
}
|