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,598 @@
1
+ "use strict";
2
+
3
+ // cli/core/agent-session.js — the PURE agent-session evidence core (T-68.1, EPIC-68 "AgentTrace").
4
+ //
5
+ // WHAT THIS IS
6
+ // A transport/filesystem-agnostic core that turns an ORDERED log of AI-agent session events
7
+ // (prompts, completions, tool calls/results, notes) into tamper-evident, selectively-REDACTABLE
8
+ // evidence. It provides:
9
+ //
10
+ // (a) a CANONICAL EVENT SCHEMA
11
+ // { seq, ts, actor, type, payload | payloadHash, redacted?, meta? }
12
+ // with `type` drawn from a CLOSED set (EVENT_TYPES) and STRICT validation: every failure
13
+ // is a NAMED reject `{ ok:false, reason, field? }` with a stable reason code (REASONS) —
14
+ // missing/extra/malformed fields, non-contiguous `seq`, non-string `ts`. Nothing here ever
15
+ // throws on hostile input.
16
+ //
17
+ // (b) the REDACTION-SAFE LEAF — the design decision that makes this evidentiary. Each event's
18
+ // Merkle leaf is computed over the canonical event with the payload represented by its
19
+ // HASH COMMITMENT:
20
+ // payloadHash = hashBytes(utf8(payload)) // cli/hash.js keccak256, verbatim
21
+ // leaf = hashBytes(utf8(JSON.stringify([
22
+ // LEAF_DOMAIN, seq, ts, actor, type, payloadHash, canonicalMetaJson|null ])))
23
+ // The payload bytes are NEVER in the leaf preimage — only their commitment is. So a FULL
24
+ // event (carrying `payload`) and its REDACTED twin (carrying only `payloadHash`, flagged
25
+ // `redacted: true`) derive the IDENTICAL leaf, and redacting ANY subset of a session's
26
+ // events changes NEITHER the leaves NOR the root. Verification recomputes `payloadHash`
27
+ // from `payload` when it is present (and cross-checks a carried `payloadHash` against it);
28
+ // when the payload is absent the well-formed commitment itself is what the tree binds.
29
+ // The fixed-position JSON array + JSON string escaping make the encoding unambiguous: no
30
+ // two distinct canonical events serialize to the same preimage.
31
+ //
32
+ // (c) the ORDERED LOG: sessionHead(events) -> { ok, size, root } via cli/journal-log.js
33
+ // treeHead over the event leaves REUSED VERBATIM (RFC-6962 0x00/0x01 domain separation,
34
+ // position-bound, NO sorting), plus proveEvent/verifyEvent (single-event inclusion against
35
+ // a head) and proveGrowth/verifyGrowth (append-only consistency between a mid-session
36
+ // checkpoint head and a later/final head) delegating to inclusionProof/verifyInclusion/
37
+ // consistencyProof/verifyConsistency VERBATIM. A sessionHead result doubles as the
38
+ // `{ size, root }` head object those verifiers bind sizes against.
39
+ //
40
+ // (d) redactEvent(event): the canonical redacted twin, with the round-trip invariant
41
+ // eventLeaf(redactEvent(e).event) === eventLeaf(e)
42
+ // so a packet holder can withhold any payload AFTER sealing without invalidating the head.
43
+ //
44
+ // TRUST BOUNDARY (honest, and carried into docs by T-68.4)
45
+ // - `ts` is SELF-ASSERTED metadata: this core records and binds the string but does NOT verify
46
+ // it against any clock (it has no clock). It proves "unaltered since sealed", never
47
+ // "happened at time T".
48
+ // - Garbage-in is out of scope: the head proves the LOG is intact and append-only, not that the
49
+ // log faithfully records what the agent actually did.
50
+ //
51
+ // PURITY (a hard acceptance criterion, statically guarded by the test)
52
+ // No fs / http / https / net / dns / child_process, no process.env, no clock, no randomness,
53
+ // no signing material. Requires ONLY:
54
+ // - `hashBytes` from cli/hash.js (the pure keccak over in-memory bytes — the ONE symbol
55
+ // imported; none of that module's file-walking helpers are referenced), REUSED not forked;
56
+ // - the five tree functions from cli/journal-log.js, REUSED not forked;
57
+ // - the pure byte helper `toUtf8Bytes` from ethers.
58
+ // Every exported function is TOTAL: hostile input yields a named `{ ok:false, reason }` verdict
59
+ // (or `null` from the leaf generator), never an exception, and results are fully deterministic.
60
+
61
+ const { hashBytes } = require("../hash");
62
+ const {
63
+ treeHead,
64
+ inclusionProof,
65
+ verifyInclusion,
66
+ consistencyProof,
67
+ verifyConsistency,
68
+ } = require("../journal-log");
69
+ const { toUtf8Bytes } = require("ethers");
70
+
71
+ // ---------------------------------------------------------------------------------------------------
72
+ // Canonical schema constants.
73
+ // ---------------------------------------------------------------------------------------------------
74
+
75
+ // The CLOSED set of event types. A session log is a conversation between an agent and its tools;
76
+ // these five cover it. Extending the set is a schema version bump (change LEAF_DOMAIN too) — an
77
+ // unknown `type` is a NAMED reject, never silently accepted.
78
+ const EVENT_TYPES = Object.freeze(["prompt", "completion", "tool_call", "tool_result", "note"]);
79
+
80
+ // The exhaustive field set of a canonical event. Any other key is EVENT_UNKNOWN_FIELD: strictness
81
+ // here is what makes the leaf encoding total — every byte of an accepted event is either bound
82
+ // into the leaf (seq/ts/actor/type/payloadHash/meta) or committed by it (payload).
83
+ const EVENT_FIELDS = Object.freeze([
84
+ "seq",
85
+ "ts",
86
+ "actor",
87
+ "type",
88
+ "payload",
89
+ "payloadHash",
90
+ "redacted",
91
+ "meta",
92
+ ]);
93
+
94
+ // Domain tag bound into every leaf preimage, so an agent-session leaf can never collide with any
95
+ // other artifact this project hashes. Bump the version if the encoding ever changes.
96
+ const LEAF_DOMAIN = "vh.agent-session/v1:event-leaf";
97
+
98
+ // Maximum nesting depth accepted for `meta`. The cap keeps canonicalization total on hostile
99
+ // input: a cyclic or absurdly deep object bottoms out at the cap and is REJECTED (EVENT_BAD_META)
100
+ // instead of overflowing the stack.
101
+ const META_MAX_DEPTH = 32;
102
+
103
+ // Maximum TOTAL number of values canonicalization may visit for one `meta`. The DEPTH cap alone
104
+ // stops cycles and deep-linear objects but NOT breadth blowup from SHARED references: a meta that
105
+ // reuses one child twice per level (`let n={leaf:1}; for(i<24) n={a:n,b:n};`) is O(24) objects in
106
+ // memory yet, without a budget, forces ~2^24 recursive visits (confirmed: OOM-kill / uncatchable
107
+ // SIGKILL, not a named verdict). A per-canonicalization work budget makes the cost O(budget)
108
+ // regardless of object-graph shape, so shared-DAG meta is REJECTED (EVENT_BAD_META), never a hang.
109
+ // Generous enough that any realistic JSON-shaped metadata passes; JSON text cannot even express
110
+ // sharing, so JSON.parse'd callers never approach it.
111
+ const META_MAX_NODES = 100000;
112
+
113
+ // Stable, named reason codes — the verdict contract callers (and the T-68.2 CLI) rely on.
114
+ const REASONS = Object.freeze({
115
+ EVENT_NOT_OBJECT: "EVENT_NOT_OBJECT",
116
+ EVENT_UNKNOWN_FIELD: "EVENT_UNKNOWN_FIELD",
117
+ EVENT_BAD_SEQ: "EVENT_BAD_SEQ",
118
+ EVENT_BAD_TS: "EVENT_BAD_TS",
119
+ EVENT_BAD_ACTOR: "EVENT_BAD_ACTOR",
120
+ EVENT_BAD_TYPE: "EVENT_BAD_TYPE",
121
+ EVENT_BAD_PAYLOAD: "EVENT_BAD_PAYLOAD",
122
+ EVENT_BAD_PAYLOAD_HASH: "EVENT_BAD_PAYLOAD_HASH",
123
+ EVENT_PAYLOAD_HASH_MISMATCH: "EVENT_PAYLOAD_HASH_MISMATCH",
124
+ EVENT_BAD_REDACTED_FLAG: "EVENT_BAD_REDACTED_FLAG",
125
+ EVENT_REDACTED_WITH_PAYLOAD: "EVENT_REDACTED_WITH_PAYLOAD",
126
+ EVENT_UNFLAGGED_REDACTION: "EVENT_UNFLAGGED_REDACTION",
127
+ EVENT_MISSING_PAYLOAD: "EVENT_MISSING_PAYLOAD",
128
+ EVENT_BAD_META: "EVENT_BAD_META",
129
+ SESSION_NOT_ARRAY: "SESSION_NOT_ARRAY",
130
+ SESSION_SEQ_NOT_CONTIGUOUS: "SESSION_SEQ_NOT_CONTIGUOUS",
131
+ INDEX_OUT_OF_RANGE: "INDEX_OUT_OF_RANGE",
132
+ PROOF_MALFORMED: "PROOF_MALFORMED",
133
+ PROOF_SEQ_MISMATCH: "PROOF_SEQ_MISMATCH",
134
+ EVENT_NOT_IN_HEAD: "EVENT_NOT_IN_HEAD",
135
+ GROWTH_RANGE: "GROWTH_RANGE",
136
+ GROWTH_NOT_APPEND_ONLY: "GROWTH_NOT_APPEND_ONLY",
137
+ HOSTILE_INPUT: "HOSTILE_INPUT",
138
+ });
139
+
140
+ const HEX32_RE = /^0x[0-9a-fA-F]{64}$/;
141
+
142
+ function _isHex32(x) {
143
+ return typeof x === "string" && HEX32_RE.test(x);
144
+ }
145
+
146
+ // A "plain" object: prototype is Object.prototype or null. Rejecting exotic objects (class
147
+ // instances, Maps, proxies-over-arrays, etc.) keeps canonicalization honest — what we hash is
148
+ // exactly the JSON-shaped data the caller could write to disk and read back.
149
+ function _isPlainObject(v) {
150
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
151
+ const proto = Object.getPrototypeOf(v);
152
+ return proto === Object.prototype || proto === null;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------------------------------
156
+ // Canonical JSON — deterministic, total serialization for `meta`.
157
+ // - objects serialize with keys SORTED (UTF-16 code-unit order), so two semantically equal metas
158
+ // always hash identically regardless of insertion order;
159
+ // - only JSON-representable values are accepted: null, booleans, FINITE numbers, strings, arrays,
160
+ // plain objects. Anything else (undefined, functions, symbols, bigints, NaN/Infinity, class
161
+ // instances) -> null (reject);
162
+ // - depth is capped at META_MAX_DEPTH, which also terminates cycles -> null (reject);
163
+ // - a shared, mutable `budget` counts EVERY value visited across the whole traversal and caps it
164
+ // at META_MAX_NODES, so a shared-reference DAG (O(k) objects, ~2^k visits) is REJECTED instead
165
+ // of hanging/OOM-ing. Preferred over a WeakSet-on-path (catches cycles but not diamond fan-out)
166
+ // and over a never-removed identity Set (which would spuriously reject legitimate immutable
167
+ // sub-object reuse). Callers pass a fresh `{ n: 0 }` per `meta`.
168
+ // Returns the canonical JSON text, or null if the value is not canonicalizable (or blows the budget).
169
+ // ---------------------------------------------------------------------------------------------------
170
+
171
+ function _canonicalJson(value, depth, budget) {
172
+ if (depth > META_MAX_DEPTH) return null;
173
+ if (++budget.n > META_MAX_NODES) return null; // total-work budget: bounds shared-reference fan-out
174
+ if (value === null) return "null";
175
+ const t = typeof value;
176
+ if (t === "boolean") return value ? "true" : "false";
177
+ if (t === "number") return Number.isFinite(value) ? JSON.stringify(value) : null;
178
+ if (t === "string") return JSON.stringify(value);
179
+ if (Array.isArray(value)) {
180
+ const parts = [];
181
+ for (const item of value) {
182
+ const p = _canonicalJson(item, depth + 1, budget);
183
+ if (p === null) return null;
184
+ parts.push(p);
185
+ }
186
+ return "[" + parts.join(",") + "]";
187
+ }
188
+ if (_isPlainObject(value)) {
189
+ const keys = Object.keys(value).sort();
190
+ const parts = [];
191
+ for (const k of keys) {
192
+ const p = _canonicalJson(value[k], depth + 1, budget);
193
+ if (p === null) return null;
194
+ parts.push(JSON.stringify(k) + ":" + p);
195
+ }
196
+ return "{" + parts.join(",") + "}";
197
+ }
198
+ return null;
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------------------------------
202
+ // Payload commitment.
203
+ // ---------------------------------------------------------------------------------------------------
204
+
205
+ /**
206
+ * The hash commitment of a payload: cli/hash.js `hashBytes` (keccak256, the primitive every other
207
+ * artifact in this project already trusts) over the UTF-8 bytes of the payload STRING. Payloads are
208
+ * strings by contract — a caller with structured data serializes it (deterministically, if they
209
+ * ever want to re-derive the commitment) BEFORE logging. TOTAL: non-string -> null, and a string
210
+ * that is not valid UTF-16 (a lone/unpaired surrogate — legal in JS, produced by truncated log
211
+ * fields or UTF-16 slicing) -> null too, since ethers' toUtf8Bytes THROWS INVALID_ARGUMENT on it.
212
+ * Never throws.
213
+ *
214
+ * @param {string} payload
215
+ * @returns {string|null} 0x bytes32 (lowercase), or null if `payload` is not a UTF-8-encodable string.
216
+ */
217
+ function payloadHash(payload) {
218
+ if (typeof payload !== "string") return null;
219
+ try {
220
+ return hashBytes(toUtf8Bytes(payload));
221
+ } catch (_) {
222
+ // Lone/unpaired UTF-16 surrogate: a legal JS string with no UTF-8 encoding. Return null so the
223
+ // function stays TOTAL, exactly like every other exported entry point in this module.
224
+ return null;
225
+ }
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------------------------------
229
+ // Event validation.
230
+ // ---------------------------------------------------------------------------------------------------
231
+
232
+ /**
233
+ * STRICT validation of one canonical event. Never throws; every failure is a named reject.
234
+ *
235
+ * Accepted shapes (exactly two):
236
+ * FULL: `payload` is a string; `redacted` absent or false; `payloadHash` optional but, if
237
+ * present, MUST equal the recomputed commitment (case-insensitively).
238
+ * REDACTED: `payload` absent; `payloadHash` is a 0x-bytes32 hex commitment; `redacted` MUST be
239
+ * exactly true (a missing payload without the explicit flag is EVENT_UNFLAGGED_REDACTION
240
+ * — redaction is always a declared act, never an accident).
241
+ * Common to both: `seq` a non-negative safe integer; `ts` any string (SELF-ASSERTED, untrusted —
242
+ * bound into the leaf but never interpreted); `actor` a non-empty string; `type` from EVENT_TYPES;
243
+ * `meta` (optional) any canonicalizable JSON value; NO other keys.
244
+ *
245
+ * @param {object} event
246
+ * @returns {{ ok: true, redacted: boolean, payloadHash: string, metaJson: string|null }
247
+ * | { ok: false, reason: string, field?: string }}
248
+ * On ok: `payloadHash` is the normalized (lowercase) commitment — recomputed from `payload`
249
+ * when present, taken from the carried commitment when redacted; `metaJson` is the
250
+ * canonical meta text (null when `meta` is absent).
251
+ */
252
+ function validateEvent(event) {
253
+ try {
254
+ if (!_isPlainObject(event)) return { ok: false, reason: REASONS.EVENT_NOT_OBJECT };
255
+ for (const k of Object.keys(event)) {
256
+ if (!EVENT_FIELDS.includes(k)) {
257
+ return { ok: false, reason: REASONS.EVENT_UNKNOWN_FIELD, field: k };
258
+ }
259
+ }
260
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
261
+ return { ok: false, reason: REASONS.EVENT_BAD_SEQ, field: "seq" };
262
+ }
263
+ if (typeof event.ts !== "string") {
264
+ return { ok: false, reason: REASONS.EVENT_BAD_TS, field: "ts" };
265
+ }
266
+ if (typeof event.actor !== "string" || event.actor.length === 0) {
267
+ return { ok: false, reason: REASONS.EVENT_BAD_ACTOR, field: "actor" };
268
+ }
269
+ if (!EVENT_TYPES.includes(event.type)) {
270
+ return { ok: false, reason: REASONS.EVENT_BAD_TYPE, field: "type" };
271
+ }
272
+ const hasPayload = "payload" in event;
273
+ const hasHash = "payloadHash" in event;
274
+ if (hasPayload && typeof event.payload !== "string") {
275
+ return { ok: false, reason: REASONS.EVENT_BAD_PAYLOAD, field: "payload" };
276
+ }
277
+ if (hasHash && !_isHex32(event.payloadHash)) {
278
+ return { ok: false, reason: REASONS.EVENT_BAD_PAYLOAD_HASH, field: "payloadHash" };
279
+ }
280
+ if ("redacted" in event && typeof event.redacted !== "boolean") {
281
+ return { ok: false, reason: REASONS.EVENT_BAD_REDACTED_FLAG, field: "redacted" };
282
+ }
283
+ if (!hasPayload && !hasHash) {
284
+ return { ok: false, reason: REASONS.EVENT_MISSING_PAYLOAD, field: "payload" };
285
+ }
286
+ if (event.redacted === true && hasPayload) {
287
+ return { ok: false, reason: REASONS.EVENT_REDACTED_WITH_PAYLOAD, field: "redacted" };
288
+ }
289
+ if (event.redacted === true && !hasHash) {
290
+ return { ok: false, reason: REASONS.EVENT_BAD_PAYLOAD_HASH, field: "payloadHash" };
291
+ }
292
+ if (!hasPayload && event.redacted !== true) {
293
+ return { ok: false, reason: REASONS.EVENT_UNFLAGGED_REDACTION, field: "redacted" };
294
+ }
295
+
296
+ // The commitment: recomputed from the payload when present (and cross-checked against any
297
+ // carried payloadHash), taken from the carried commitment when redacted.
298
+ let commitment;
299
+ if (hasPayload) {
300
+ commitment = payloadHash(event.payload);
301
+ if (commitment === null) {
302
+ // A lone/unpaired UTF-16 surrogate is a legal JS string with no UTF-8 commitment: a
303
+ // SPECIFIC, named reject for the payload field rather than a generic HOSTILE_INPUT.
304
+ return { ok: false, reason: REASONS.EVENT_BAD_PAYLOAD, field: "payload" };
305
+ }
306
+ if (hasHash && commitment !== event.payloadHash.toLowerCase()) {
307
+ return { ok: false, reason: REASONS.EVENT_PAYLOAD_HASH_MISMATCH, field: "payloadHash" };
308
+ }
309
+ } else {
310
+ commitment = event.payloadHash.toLowerCase();
311
+ }
312
+
313
+ let metaJson = null;
314
+ if ("meta" in event) {
315
+ metaJson = _canonicalJson(event.meta, 0, { n: 0 });
316
+ if (metaJson === null) return { ok: false, reason: REASONS.EVENT_BAD_META, field: "meta" };
317
+ }
318
+
319
+ return { ok: true, redacted: !hasPayload, payloadHash: commitment, metaJson };
320
+ } catch (_) {
321
+ // Hostile exotica (throwing getters, etc.) must never escape as an exception.
322
+ return { ok: false, reason: REASONS.HOSTILE_INPUT };
323
+ }
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------------------------------
327
+ // The redaction-safe leaf.
328
+ // ---------------------------------------------------------------------------------------------------
329
+
330
+ /**
331
+ * The Merkle LEAF VALUE of one canonical event — the redaction-safe commitment handed to
332
+ * cli/journal-log.js treeHead (which applies its own RFC-6962 0x00 leaf tag on top).
333
+ *
334
+ * The preimage is the fixed-position JSON array
335
+ * [ LEAF_DOMAIN, seq, ts, actor, type, payloadHash, canonicalMetaJson|null ]
336
+ * so every bound field edit changes the leaf, while the payload participates ONLY via its
337
+ * commitment: a full event and its redacted twin hash to the IDENTICAL leaf. The presentation-only
338
+ * `redacted` flag is deliberately NOT bound (it is derivable: payload absent <=> redacted). TOTAL:
339
+ * invalid event -> null (journal-log generator convention), never throws.
340
+ *
341
+ * @param {object} event a canonical event (full or redacted).
342
+ * @returns {string|null} 0x bytes32 leaf value, or null if the event does not validate.
343
+ */
344
+ function eventLeaf(event) {
345
+ try {
346
+ const v = validateEvent(event);
347
+ if (!v.ok) return null;
348
+ const encoded = JSON.stringify([
349
+ LEAF_DOMAIN,
350
+ event.seq,
351
+ event.ts,
352
+ event.actor,
353
+ event.type,
354
+ v.payloadHash,
355
+ v.metaJson,
356
+ ]);
357
+ return hashBytes(toUtf8Bytes(encoded));
358
+ } catch (_) {
359
+ return null;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * The canonical REDACTED TWIN of an event: payload dropped, its commitment carried, `redacted: true`
365
+ * declared, `meta` (when present) deep-copied in canonical form. Round-trip invariant (tested):
366
+ * eventLeaf(redactEvent(e).event) === eventLeaf(e)
367
+ * Idempotent: redacting an already-redacted event yields an equal twin. Never throws.
368
+ *
369
+ * @param {object} event a canonical event (full or redacted).
370
+ * @returns {{ ok: true, event: object } | { ok: false, reason: string, field?: string }}
371
+ */
372
+ function redactEvent(event) {
373
+ try {
374
+ const v = validateEvent(event);
375
+ if (!v.ok) return v;
376
+ const twin = {
377
+ seq: event.seq,
378
+ ts: event.ts,
379
+ actor: event.actor,
380
+ type: event.type,
381
+ payloadHash: v.payloadHash,
382
+ redacted: true,
383
+ };
384
+ // Canonical deep copy: the twin never aliases caller-mutable state.
385
+ if (v.metaJson !== null) twin.meta = JSON.parse(v.metaJson);
386
+ return { ok: true, event: twin };
387
+ } catch (_) {
388
+ return { ok: false, reason: REASONS.HOSTILE_INPUT };
389
+ }
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------------------------------
393
+ // The ordered session log.
394
+ // ---------------------------------------------------------------------------------------------------
395
+
396
+ /**
397
+ * Validate a whole session: an ARRAY of canonical events whose `seq` values are CONTIGUOUS from 0
398
+ * (events[i].seq === i — `seq` is the tree position, which is what makes an inclusion proof bind an
399
+ * event to its place in the conversation). Named, LOCATED rejects; never throws.
400
+ *
401
+ * @param {object[]} events
402
+ * @returns {{ ok: true, size: number } | { ok: false, reason: string, index?: number, field?: string }}
403
+ */
404
+ function validateSession(events) {
405
+ try {
406
+ if (!Array.isArray(events)) return { ok: false, reason: REASONS.SESSION_NOT_ARRAY };
407
+ for (let i = 0; i < events.length; i++) {
408
+ const v = validateEvent(events[i]);
409
+ if (!v.ok) return { ok: false, reason: v.reason, index: i, field: v.field };
410
+ if (events[i].seq !== i) {
411
+ return { ok: false, reason: REASONS.SESSION_SEQ_NOT_CONTIGUOUS, index: i };
412
+ }
413
+ }
414
+ return { ok: true, size: events.length };
415
+ } catch (_) {
416
+ return { ok: false, reason: REASONS.HOSTILE_INPUT };
417
+ }
418
+ }
419
+
420
+ /**
421
+ * The session HEAD: cli/journal-log.js `treeHead` (REUSED VERBATIM — RFC-6962, position-bound,
422
+ * no sorting) over the ordered event leaves. Because leaves are redaction-safe, a fully or
423
+ * partially redacted session derives the IDENTICAL head as the full one.
424
+ *
425
+ * The ok-result is itself a valid `{ size, root }` head object, so it can be handed directly to
426
+ * verifyEvent/verifyGrowth (and to journal-log's own verifiers), which then BIND the size.
427
+ * An empty session is a legal (pre-first-event) checkpoint: { size: 0, root: EMPTY_ROOT }.
428
+ *
429
+ * @param {object[]} events
430
+ * @returns {{ ok: true, size: number, root: string }
431
+ * | { ok: false, reason: string, index?: number, field?: string }}
432
+ */
433
+ function sessionHead(events) {
434
+ try {
435
+ const s = validateSession(events);
436
+ if (!s.ok) return s;
437
+ const head = treeHead(events.map((e) => eventLeaf(e)));
438
+ if (head.root === null) return { ok: false, reason: REASONS.HOSTILE_INPUT }; // unreachable post-validation
439
+ return { ok: true, size: head.size, root: head.root };
440
+ } catch (_) {
441
+ return { ok: false, reason: REASONS.HOSTILE_INPUT };
442
+ }
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------------------------------
446
+ // Single-event inclusion: proveEvent -> verifyEvent.
447
+ // ---------------------------------------------------------------------------------------------------
448
+
449
+ /**
450
+ * Build a disclosure proof for the event at `index`: the event itself (full or redacted, exactly as
451
+ * held — redact first for a redacted disclosure) plus the journal-log inclusion path. The raw leaf
452
+ * is deliberately NOT carried: verifyEvent must re-derive it from the disclosed event, so the proof
453
+ * is bound to the event DATA, never to a self-asserted hash.
454
+ *
455
+ * @param {object[]} events the full (or redacted-twin) session.
456
+ * @param {number} index 0 <= index < events.length.
457
+ * @returns {{ ok: true, proof: { event: object, inclusion: { leafIndex: number, treeSize: number, path: string[] } } }
458
+ * | { ok: false, reason: string, index?: number, field?: string }}
459
+ */
460
+ function proveEvent(events, index) {
461
+ try {
462
+ const s = validateSession(events);
463
+ if (!s.ok) return s;
464
+ if (!Number.isInteger(index) || index < 0 || index >= events.length) {
465
+ return { ok: false, reason: REASONS.INDEX_OUT_OF_RANGE };
466
+ }
467
+ const ip = inclusionProof(events.map((e) => eventLeaf(e)), index);
468
+ if (ip === null) return { ok: false, reason: REASONS.HOSTILE_INPUT }; // unreachable post-validation
469
+ return {
470
+ ok: true,
471
+ proof: {
472
+ // Deep copy (events validate as JSON-shaped data), so the proof never aliases caller state.
473
+ event: JSON.parse(JSON.stringify(events[index])),
474
+ inclusion: { leafIndex: ip.leafIndex, treeSize: ip.treeSize, path: ip.path },
475
+ },
476
+ };
477
+ } catch (_) {
478
+ return { ok: false, reason: REASONS.HOSTILE_INPUT };
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Verify a single-event disclosure against a TRUSTED head.
484
+ *
485
+ * Re-validates the disclosed event, recomputes `payloadHash` from `payload` when present (checking
486
+ * any carried commitment) or takes the commitment when redacted, re-derives the LEAF from that —
487
+ * never trusting a carried hash — checks `seq === leafIndex` (the event's claimed position IS its
488
+ * tree position), then delegates to cli/journal-log.js verifyInclusion VERBATIM. Passing the full
489
+ * `{ size, root }` head (e.g. a sessionHead result) also BINDS the tree size, so a proof replayed
490
+ * against a different-sized head is rejected outright. Never throws.
491
+ *
492
+ * @param {{ event: object, inclusion: { leafIndex: number, treeSize: number, path: string[] } }} proof
493
+ * @param {string|{size:number,root:string}} head trusted root, or full head (RECOMMENDED).
494
+ * @returns {{ ok: true, seq: number, redacted: boolean } | { ok: false, reason: string, field?: string }}
495
+ */
496
+ function verifyEvent(proof, head) {
497
+ try {
498
+ if (!_isPlainObject(proof)) return { ok: false, reason: REASONS.PROOF_MALFORMED };
499
+ const v = validateEvent(proof.event);
500
+ if (!v.ok) return { ok: false, reason: v.reason, field: v.field };
501
+ const inc = proof.inclusion;
502
+ if (!_isPlainObject(inc)) return { ok: false, reason: REASONS.PROOF_MALFORMED };
503
+ if (proof.event.seq !== inc.leafIndex) {
504
+ return { ok: false, reason: REASONS.PROOF_SEQ_MISMATCH };
505
+ }
506
+ const leaf = eventLeaf(proof.event);
507
+ if (leaf === null) return { ok: false, reason: REASONS.PROOF_MALFORMED }; // unreachable post-validation
508
+ const included = verifyInclusion(
509
+ { leaf, leafIndex: inc.leafIndex, treeSize: inc.treeSize, path: inc.path },
510
+ head
511
+ );
512
+ if (!included) return { ok: false, reason: REASONS.EVENT_NOT_IN_HEAD };
513
+ return { ok: true, seq: proof.event.seq, redacted: v.redacted };
514
+ } catch (_) {
515
+ return { ok: false, reason: REASONS.HOSTILE_INPUT };
516
+ }
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------------------------------
520
+ // Append-only growth: proveGrowth -> verifyGrowth.
521
+ // ---------------------------------------------------------------------------------------------------
522
+
523
+ /**
524
+ * Build the append-only consistency proof between the size-`firstSize` checkpoint prefix and the
525
+ * size-`secondSize` (default: full) prefix of `events`, delegating to cli/journal-log.js
526
+ * consistencyProof VERBATIM. 1 <= firstSize <= secondSize <= events.length.
527
+ *
528
+ * @param {object[]} events
529
+ * @param {number} firstSize the earlier checkpoint size (m).
530
+ * @param {number} [secondSize] the later size (n); defaults to events.length.
531
+ * @returns {{ ok: true, proof: { firstSize: number, secondSize: number, path: string[] } }
532
+ * | { ok: false, reason: string, index?: number, field?: string }}
533
+ */
534
+ function proveGrowth(events, firstSize, secondSize) {
535
+ try {
536
+ const s = validateSession(events);
537
+ if (!s.ok) return s;
538
+ const n = secondSize === undefined ? events.length : secondSize;
539
+ if (
540
+ !Number.isInteger(firstSize) ||
541
+ !Number.isInteger(n) ||
542
+ firstSize < 1 ||
543
+ n < firstSize ||
544
+ n > events.length
545
+ ) {
546
+ return { ok: false, reason: REASONS.GROWTH_RANGE };
547
+ }
548
+ const cp = consistencyProof(events.map((e) => eventLeaf(e)), firstSize, n);
549
+ if (cp === null) return { ok: false, reason: REASONS.HOSTILE_INPUT }; // unreachable post-validation
550
+ return { ok: true, proof: cp };
551
+ } catch (_) {
552
+ return { ok: false, reason: REASONS.HOSTILE_INPUT };
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Verify that `laterHead` is an APPEND-ONLY extension of `earlierHead` — i.e. that no event at or
558
+ * before the checkpoint was rewritten, reordered, dropped or inserted between the two heads.
559
+ * Delegates to cli/journal-log.js verifyConsistency VERBATIM; passing full `{ size, root }` heads
560
+ * (e.g. sessionHead results — RECOMMENDED) also BINDS both sizes, so a proof lying about either
561
+ * size is rejected outright. Never throws.
562
+ *
563
+ * @param {string|{size:number,root:string}} earlierHead the checkpoint head (size m).
564
+ * @param {string|{size:number,root:string}} laterHead the later/final head (size n >= m).
565
+ * @param {{ firstSize: number, secondSize: number, path: string[] }} proof
566
+ * @returns {{ ok: true } | { ok: false, reason: string }}
567
+ */
568
+ function verifyGrowth(earlierHead, laterHead, proof) {
569
+ try {
570
+ if (!_isPlainObject(proof)) return { ok: false, reason: REASONS.PROOF_MALFORMED };
571
+ const consistent = verifyConsistency(proof, earlierHead, laterHead);
572
+ if (!consistent) return { ok: false, reason: REASONS.GROWTH_NOT_APPEND_ONLY };
573
+ return { ok: true };
574
+ } catch (_) {
575
+ return { ok: false, reason: REASONS.HOSTILE_INPUT };
576
+ }
577
+ }
578
+
579
+ module.exports = {
580
+ // Schema + verdict contract.
581
+ EVENT_TYPES,
582
+ EVENT_FIELDS,
583
+ LEAF_DOMAIN,
584
+ META_MAX_DEPTH,
585
+ META_MAX_NODES,
586
+ REASONS,
587
+ // The core operations.
588
+ payloadHash,
589
+ validateEvent,
590
+ eventLeaf,
591
+ redactEvent,
592
+ validateSession,
593
+ sessionHead,
594
+ proveEvent,
595
+ verifyEvent,
596
+ proveGrowth,
597
+ verifyGrowth,
598
+ };