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,854 @@
1
+ "use strict";
2
+
3
+ // TrustLedger — seal.js (EPIC-26, T-26.1; T-30.2 refactor)
4
+ //
5
+ // THE RECONCILIATION SEAL — a tamper-evident, content-addressed wrapper around a
6
+ // reconciliation packet, built on the project's ORIGINAL provenance core.
7
+ //
8
+ // THE PROBLEM THIS SOLVES.
9
+ // `vh trust reconcile --out <dir>` writes the audit-ready HTML+CSV packet a broker
10
+ // hands a state real-estate examiner months later. That packet is a PRINTOUT: nothing
11
+ // lets the examiner (or the broker defending themselves) prove "this is the EXACT packet
12
+ // TrustLedger produced from these EXACT source files, byte-for-byte unaltered." A text
13
+ // editor can silently rewrite a dollar figure and nothing detects it. `close.js`'s
14
+ // `inputsDigest` is a SHA-256 over the close SUMMARY only — by its own NatSpec "NOT a
15
+ // cryptographic proof of the underlying source files." So the lead product's headline
16
+ // deliverable ships UNSEALED.
17
+ //
18
+ // THE MOVE — reuse the proven manifest/attestation core VERBATIM.
19
+ // This module is now a THIN ADAPTER over the GENERIC, product-agnostic packet-seal core
20
+ // `cli/core/packetseal.js` (T-30.2). It does NOT re-implement hashing, leaf construction,
21
+ // the root re-derivation, the per-file MATCH/CHANGED/MISSING/UNEXPECTED localization, or the
22
+ // signed-attestation wrap. It supplies ONLY the TrustLedger framing: the seal `kind`
23
+ // (`SEAL_KIND`), and a verdict/role HEADER — the opaque, canonicalizable { relPath, content }
24
+ // pair binding the reconcile's PASS/FAIL verdict + report date + each input's logical ROLE into
25
+ // the SAME committed root as the files. It then projects the core's flat `files` view back into
26
+ // the TrustLedger inputs/outputs(+role)/verdict shape its callers already consume — byte-for-byte
27
+ // identical to before, with the same `__trustledger.seal-header__v1` sentinel and the same
28
+ // localized verdict/role change detection.
29
+ //
30
+ // PURE + I/O-FREE.
31
+ // Every helper here is pure: the CALLER (the CLI) does the file READING and hands in
32
+ // already-loaded `{ relPath, bytes }` entries. `buildSeal` / `validateSeal` / `verifySeal`
33
+ // never touch the filesystem, the clock, the network, or a key — given the same inputs they
34
+ // return byte-identical results.
35
+ //
36
+ // HONEST POSTURE — what the seal DOES and DOES NOT prove.
37
+ // * TAMPER-EVIDENCE (what it DOES prove): the inputs + packet are byte-for-byte what was
38
+ // sealed, AND the recorded reconcile VERDICT (pass/reportDate/period) and each input's logical
39
+ // ROLE are bound into the SAME root. Any edit/rename/add/remove to any sealed file — OR any
40
+ // edit to the verdict/date/period, OR any swap of an input's role — changes the root, and
41
+ // `verifySeal` LOCALIZES a file change to the exact file (MATCH / CHANGED / MISSING /
42
+ // UNEXPECTED) and a verdict/role change to the seal HEADER. The verdict+roles ride the SAME
43
+ // committed structure as the files (a synthetic, reserved HEADER leaf), so the seal's headline
44
+ // PASS/FAIL is tamper-EVIDENT, not a free-floating annotation.
45
+ // * TIMESTAMP (what it does NOT prove): the reportDate is bound into the root so it cannot be
46
+ // edited UNDETECTED, but a bound date is still only a self-asserted RECORDED FACT — the root
47
+ // proves "this date is the one that was sealed", NOT that the sealing actually HAPPENED then. A
48
+ // trusted "sealed at time T" still rides the human-owned trust-root (P-3's signing / timestamp
49
+ // leg). The seal SAYS so and references — never executes — the human-gated sign/timestamp steps.
50
+ // * LEGAL MEANING (what it does NOT prove): the seal does NOT validate whether the
51
+ // reconciliation is CORRECT or COMPLIANT. The CPA review still governs.
52
+ // * UNTRUSTED TRANSPORT CONTAINER: consistent with docs/TRUST-BOUNDARIES.md, the seal is an
53
+ // UNTRUSTED hint. `verifySeal` is AUTHORITATIVE by RE-COMPUTING the manifest/root from the
54
+ // supplied bytes; the seal's stored hashes are merely the EXPECTATION it checks against.
55
+ //
56
+ // The seal MAY be WRAPPED by the existing `cli/core/attestation.js` signed-attestation
57
+ // envelope (the seal's canonical bytes become the attestation payload) so a human can vouch
58
+ // for it via the SAME shared signing path — no new scheme. That signature proves WHO vouched,
59
+ // still not a trusted timestamp.
60
+
61
+ const coreManifest = require("../cli/core/manifest");
62
+ const packetseal = require("../cli/core/packetseal");
63
+ const { pathLeaf } = require("../cli/hash");
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Identity. The seal has its OWN `kind`/`schemaVersion`, disjoint from the
67
+ // dataset/parcel manifests so a seal can never be confused for one of them.
68
+ // `validateSeal` REJECTS any unsupported version rather than guessing.
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const SEAL_KIND = "trustledger.reconcile-seal";
72
+ const SEAL_SCHEMA_VERSION = 1;
73
+ const SUPPORTED_SEAL_SCHEMA_VERSIONS = Object.freeze([1]);
74
+
75
+ // Same 0x + 64-hex shape the manifest core validates against — imported from the core so the
76
+ // two can never drift.
77
+ const HEX32_RE = coreManifest.HEX32_RE;
78
+
79
+ // A strict "YYYY-MM-DD" report date, identical to the shape close.js / report.js use.
80
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
81
+
82
+ // The fixed, ordered set of logical INPUT roles a three-way trust reconciliation consumes. A
83
+ // seal's `inputs` must name each role at most once (no duplicate role) and each role must be one
84
+ // of these (no unknown role) — so the partition is well-defined and an examiner reads the same
85
+ // three sources every time. `book` is the broker's ledger; `rentroll` is the property sub-ledger.
86
+ const INPUT_ROLES = Object.freeze(["bank", "book", "rentroll"]);
87
+
88
+ // The in-band trust caveat carried in EVERY seal, stated in ONE place so it can never drift from
89
+ // the NatSpec above. It is the load-bearing honesty of the artifact.
90
+ const SEAL_TRUST_NOTE =
91
+ "This reconciliation seal is TAMPER-EVIDENT, not a trusted timestamp and not a legal opinion. Its " +
92
+ "Merkle `root` commits to the full set of (relPath, content) pairs across the source inputs AND " +
93
+ "every emitted packet file, PLUS a reserved HEADER leaf binding the recorded verdict " +
94
+ "(pass/reportDate/period) and each input's logical role: any edit, rename, add, or remove of a " +
95
+ "file — or any edit of the verdict/date/period or swap of an input role — changes the root, and " +
96
+ "verifySeal localizes a file change to the exact file and a verdict/role change to the header. It " +
97
+ "does NOT prove WHEN the sealing actually happened (the bound reportDate cannot be edited " +
98
+ "undetected, but a self-asserted date still rides the human trust-root P-3 — standing up a real " +
99
+ "signing key or timestamp anchor is needs-human) and it does NOT validate the legal MEANING of " +
100
+ "the reconciliation (the CPA review still governs). The seal is an UNTRUSTED transport container: " +
101
+ "verifySeal RE-DERIVES the root from the bytes you supply — it never trusts the seal's own hashes.";
102
+
103
+ // The reserved relPath of the synthetic HEADER entry that binds the verdict + input roles into the
104
+ // SAME committed root as the files. It uses a sentinel that can never collide with a real packet
105
+ // file (no real reconcile path begins with this prefix); the core rejects any caller file occupying
106
+ // it, so the header is unforgeable from the file side. The header's "content" is the canonical bytes
107
+ // of { verdict, roles } (see _headerBytes), hashed + path-bound by the SAME pathLeaf convention every
108
+ // other entry uses — so binding the verdict/roles re-uses the core verbatim, with no second hashing
109
+ // scheme.
110
+ const SEAL_HEADER_RELPATH = "__trustledger.seal-header__v1";
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Errors. The adapter keeps its OWN named SealError (the TrustLedger-facing error
114
+ // surface its callers + tests assert on). Where the shared core raises a
115
+ // PacketSealError carrying a (deliberately product-agnostic) message, the adapter
116
+ // re-frames it into a SealError with the TrustLedger wording its callers expect.
117
+ // ---------------------------------------------------------------------------
118
+
119
+ class SealError extends Error {
120
+ constructor(message) {
121
+ super(message);
122
+ this.name = "SealError";
123
+ }
124
+ }
125
+
126
+ function isPlainObject(v) {
127
+ return v != null && typeof v === "object" && !Array.isArray(v);
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // _headerBytes(verdict, inputs) — the canonical "content" of the synthetic
132
+ // HEADER entry that binds the recorded VERDICT + each input's logical ROLE into
133
+ // the SAME committed root as the files.
134
+ //
135
+ // WHY A HEADER ENTRY (not a separate digest). Folding the verdict/roles into a
136
+ // reserved leaf means the EXISTING pathLeaf/buildTree convention — used verbatim
137
+ // for every real file — also commits to them. There is NO second hashing scheme:
138
+ // the header is just one more (relPath, content) pair, so editing verdict.pass,
139
+ // reportDate, period, OR swapping an input's role changes the header's content,
140
+ // hence its leaf, hence the root. The bare-seal root therefore makes the
141
+ // headline PASS/FAIL verdict (and the role partition) tamper-EVIDENT.
142
+ //
143
+ // The bytes are a deterministic JSON serialization with a FIXED key order: the
144
+ // verdict triple, then the role→relPath bindings sorted by role. Deterministic so
145
+ // build/validate/verify all recompute byte-identical header content.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function _headerBytes(verdict, inputs) {
149
+ const canonical = {
150
+ v: 1, // header layout version (lets the bound structure evolve without ambiguity)
151
+ verdict: {
152
+ pass: verdict.pass,
153
+ reportDate: verdict.reportDate,
154
+ period: verdict.period == null ? null : String(verdict.period),
155
+ },
156
+ // The role→relPath bindings, sorted by role so the bytes are order-independent. Binding the
157
+ // relPath here (not just the role) means a role can never be re-pointed at a different sealed
158
+ // file without changing the header content (and thus the root).
159
+ roles: inputs
160
+ .map((i) => ({ role: i.role, relPath: i.relPath }))
161
+ .sort((a, b) => (a.role < b.role ? -1 : a.role > b.role ? 1 : 0)),
162
+ };
163
+ return Buffer.from(JSON.stringify(canonical), "utf8");
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // The PACKET-SEAL CORE config (the TrustLedger framing). This is the entire
168
+ // product-specific surface handed to cli/core/packetseal.js:
169
+ // * kind/schemaVersion/note — the seal identity + standing trust note;
170
+ // * headerRelPath — the reserved verdict/role HEADER slot;
171
+ // * headerContentFor(seal) — re-derive the header's canonical bytes from a
172
+ // seal's OWN recorded verdict + input role bindings,
173
+ // so the core can recompute the header leaf on
174
+ // validate (and thus catch a verdict/role edit).
175
+ // The core does ALL the shared math; this just NAMES what TrustLedger binds.
176
+ // ---------------------------------------------------------------------------
177
+
178
+ const SEAL_CFG = Object.freeze({
179
+ kind: SEAL_KIND,
180
+ schemaVersion: SEAL_SCHEMA_VERSION,
181
+ supportedSchemaVersions: SUPPORTED_SEAL_SCHEMA_VERSIONS,
182
+ note: SEAL_TRUST_NOTE,
183
+ label: "trustledger reconciliation seal",
184
+ headerRelPath: SEAL_HEADER_RELPATH,
185
+ headerContentFor: (seal) => {
186
+ const inputBindings = (seal.inputs || []).map((e) => ({ role: e.role, relPath: e.relPath }));
187
+ return _headerBytes(seal.verdict, inputBindings);
188
+ },
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // _normalizeFileSet(files) — normalize + strictly validate the TrustLedger file
193
+ // set ({ inputs:[{role,relPath,bytes}], outputs:[{relPath,bytes}] }) for BUILD.
194
+ // BUILD requires all three roles' partition rules and a non-empty packet. Returns
195
+ // { inputs, outputs } of normalized entries; relPath uniqueness across the whole
196
+ // set + the reserved-header-slot rejection are enforced by the core when the flat
197
+ // entries flow through it, but we ALSO enforce the role rules + the inputs/outputs
198
+ // cardinality here (the core knows nothing of roles).
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function _normalizeFileSet(files) {
202
+ if (!isPlainObject(files)) {
203
+ throw new SealError("buildSeal requires a { inputs, outputs } file set object");
204
+ }
205
+ if (!Array.isArray(files.inputs) || files.inputs.length === 0) {
206
+ throw new SealError("seal `inputs` must be a non-empty array of { role, relPath, bytes }");
207
+ }
208
+ if (!Array.isArray(files.outputs) || files.outputs.length === 0) {
209
+ throw new SealError("seal `outputs` must be a non-empty array of { relPath, bytes }");
210
+ }
211
+
212
+ const seenRole = new Set();
213
+ const inputs = files.inputs.map((e) => {
214
+ _checkEntryShape(e, "inputs");
215
+ if (!INPUT_ROLES.includes(e.role)) {
216
+ throw new SealError(
217
+ `seal input role must be one of ${JSON.stringify(INPUT_ROLES)}, got: ${JSON.stringify(e.role)}`
218
+ );
219
+ }
220
+ if (seenRole.has(e.role)) {
221
+ throw new SealError(`seal has a duplicate input role: ${JSON.stringify(e.role)}`);
222
+ }
223
+ seenRole.add(e.role);
224
+ return { role: e.role, relPath: e.relPath, bytes: _bytesOf(e) };
225
+ });
226
+ const outputs = files.outputs.map((e) => {
227
+ _checkEntryShape(e, "outputs");
228
+ return { role: null, relPath: e.relPath, bytes: _bytesOf(e) };
229
+ });
230
+ // Cross-set relPath uniqueness + reserved-header-slot rejection happen in the core via the flat
231
+ // entries; surface a TrustLedger-worded duplicate/reserved error here so callers see the same message.
232
+ _assertDistinctPaths([...inputs, ...outputs]);
233
+ return { inputs, outputs };
234
+ }
235
+
236
+ // _normalizeSuppliedForVerify(files) — the LENIENT sibling for verifySeal: either list may be EMPTY
237
+ // (a partial supplied set, so verifySeal can localize MISSING), but per-entry strictness is identical.
238
+ function _normalizeSuppliedForVerify(files) {
239
+ if (!isPlainObject(files)) {
240
+ throw new SealError("verifySeal requires a { inputs, outputs } file set object");
241
+ }
242
+ const rawInputs = Array.isArray(files.inputs) ? files.inputs : [];
243
+ const rawOutputs = Array.isArray(files.outputs) ? files.outputs : [];
244
+
245
+ const seenRole = new Set();
246
+ const inputs = rawInputs.map((e) => {
247
+ _checkEntryShape(e, "inputs");
248
+ if (!INPUT_ROLES.includes(e.role)) {
249
+ throw new SealError(
250
+ `seal input role must be one of ${JSON.stringify(INPUT_ROLES)}, got: ${JSON.stringify(e.role)}`
251
+ );
252
+ }
253
+ if (seenRole.has(e.role)) {
254
+ throw new SealError(`seal has a duplicate input role: ${JSON.stringify(e.role)}`);
255
+ }
256
+ seenRole.add(e.role);
257
+ return { role: e.role, relPath: e.relPath, bytes: _bytesOf(e) };
258
+ });
259
+ const outputs = rawOutputs.map((e) => {
260
+ _checkEntryShape(e, "outputs");
261
+ return { role: null, relPath: e.relPath, bytes: _bytesOf(e) };
262
+ });
263
+ _assertDistinctPaths([...inputs, ...outputs]);
264
+ return { inputs, outputs };
265
+ }
266
+
267
+ function _checkEntryShape(e, where) {
268
+ if (!isPlainObject(e)) {
269
+ throw new SealError(`seal ${where} entry must be an object with relPath + bytes`);
270
+ }
271
+ if (typeof e.relPath !== "string" || e.relPath.length === 0) {
272
+ throw new SealError(`seal ${where} entry relPath must be a non-empty string`);
273
+ }
274
+ if (e.relPath === SEAL_HEADER_RELPATH) {
275
+ throw new SealError(
276
+ `seal ${where} entry relPath ${JSON.stringify(e.relPath)} is reserved for the seal header ` +
277
+ "(a real file may not occupy the bound verdict/role header slot)"
278
+ );
279
+ }
280
+ if (!(e.bytes instanceof Uint8Array) && !Buffer.isBuffer(e.bytes)) {
281
+ throw new SealError(
282
+ `seal ${where} entry ${JSON.stringify(e.relPath)} bytes must be a Buffer/Uint8Array ` +
283
+ "(seal.js is I/O-free; the caller reads the file and hands in its bytes)"
284
+ );
285
+ }
286
+ }
287
+
288
+ function _bytesOf(e) {
289
+ return Buffer.isBuffer(e.bytes) ? e.bytes : Buffer.from(e.bytes);
290
+ }
291
+
292
+ function _assertDistinctPaths(entries) {
293
+ const seen = new Set();
294
+ for (const e of entries) {
295
+ if (seen.has(e.relPath)) {
296
+ throw new SealError(
297
+ `seal has a duplicate relPath across the file set: ${JSON.stringify(e.relPath)} ` +
298
+ "(every input and output must occupy a distinct path)"
299
+ );
300
+ }
301
+ seen.add(e.relPath);
302
+ }
303
+ }
304
+
305
+ // _validateVerdictArg(verdict) — strict shape check on the recorded reconcile facts (BUILD).
306
+ function _validateVerdictArg(verdict) {
307
+ if (!isPlainObject(verdict)) {
308
+ throw new SealError("buildSeal requires a `verdict` { pass, reportDate } object");
309
+ }
310
+ if (typeof verdict.pass !== "boolean") {
311
+ throw new SealError("seal verdict.pass must be a boolean (the reconcile PASS/FAIL fact)");
312
+ }
313
+ if (!DATE_RE.test(String(verdict.reportDate || ""))) {
314
+ throw new SealError('seal verdict.reportDate must be a "YYYY-MM-DD" string');
315
+ }
316
+ if (verdict.period !== undefined && verdict.period !== null && typeof verdict.period !== "string") {
317
+ throw new SealError("seal verdict.period, when present, must be a string or null");
318
+ }
319
+ }
320
+
321
+ // _headerArgFor(inputs, verdict) — the opaque { relPath, content } HEADER handed to the core, binding
322
+ // the verdict + role→relPath partition into the SAME committed root as the files.
323
+ function _headerArgFor(inputs, verdict) {
324
+ return { relPath: SEAL_HEADER_RELPATH, content: _headerBytes(verdict, inputs) };
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // buildSeal({ files, verdict }) — assemble + strictly validate a seal.
329
+ //
330
+ // Delegates ALL hashing/root/header binding to cli/core/packetseal.js, then PROJECTS the core's
331
+ // flat `files` view into the TrustLedger inputs/outputs(+role)/verdict shape its callers consume.
332
+ // Byte-identical to the pre-refactor output.
333
+ // ---------------------------------------------------------------------------
334
+
335
+ function buildSeal(params) {
336
+ if (!isPlainObject(params)) {
337
+ throw new SealError("buildSeal requires { files, verdict }");
338
+ }
339
+ const { inputs, outputs } = _normalizeFileSet(params.files);
340
+ const verdict = params.verdict;
341
+ _validateVerdictArg(verdict);
342
+
343
+ // Hand the GENERIC core the flat file entries + the opaque verdict/role HEADER. The core re-derives
344
+ // the manifest/root over the WHOLE committed set (files + header) via the shared convention.
345
+ const flatEntries = [...inputs, ...outputs].map((e) => ({ relPath: e.relPath, bytes: e.bytes }));
346
+ let coreSeal;
347
+ try {
348
+ coreSeal = packetseal.buildSeal(
349
+ { files: { entries: flatEntries }, header: _headerArgFor(inputs, verdict) },
350
+ SEAL_CFG
351
+ );
352
+ } catch (e) {
353
+ throw _asSealError(e);
354
+ }
355
+
356
+ // Map the core's flat per-file leaves back to a relPath -> { contentHash, leaf } lookup so we can
357
+ // re-attach the per-input role + emit inputs/outputs in the TrustLedger order.
358
+ const byRelPath = new Map(coreSeal.files.map((f) => [f.relPath, f]));
359
+
360
+ // Inputs in the FIXED INPUT_ROLES order; outputs sorted by relPath. Deterministic regardless of the
361
+ // caller's array order — byte-identical to the pre-refactor emission.
362
+ const sealInputs = INPUT_ROLES.filter((r) => inputs.some((i) => i.role === r)).map((r) => {
363
+ const src = inputs.find((i) => i.role === r);
364
+ const leaf = byRelPath.get(src.relPath);
365
+ return { role: r, relPath: leaf.relPath, contentHash: leaf.contentHash, leaf: leaf.leaf };
366
+ });
367
+ const sealOutputs = outputs
368
+ .map((o) => {
369
+ const leaf = byRelPath.get(o.relPath);
370
+ return { relPath: leaf.relPath, contentHash: leaf.contentHash, leaf: leaf.leaf };
371
+ })
372
+ .sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
373
+
374
+ const seal = {
375
+ kind: SEAL_KIND,
376
+ schemaVersion: SEAL_SCHEMA_VERSION,
377
+ note: SEAL_TRUST_NOTE,
378
+ root: coreSeal.root,
379
+ fileCount: coreSeal.fileCount,
380
+ verdict: {
381
+ pass: verdict.pass,
382
+ reportDate: verdict.reportDate,
383
+ period: verdict.period == null ? null : String(verdict.period),
384
+ },
385
+ inputs: sealInputs,
386
+ outputs: sealOutputs,
387
+ };
388
+
389
+ // Self-check: the artifact we just built must itself validate, so build/validate stay in lock-step.
390
+ validateSeal(seal);
391
+ return seal;
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // validateSeal(obj) — STRICT structural + self-consistency validation of the
396
+ // TrustLedger seal shape, with the verdict/role/inputs/outputs checks the core
397
+ // cannot know, then DELEGATING the load-bearing root re-derivation (files + the
398
+ // verdict/role header) to cli/core/packetseal.js via a flat core-shaped view.
399
+ // Throws a named SealError on the FIRST problem; returns the object unchanged on success.
400
+ // ---------------------------------------------------------------------------
401
+
402
+ function validateSeal(obj) {
403
+ if (!isPlainObject(obj)) {
404
+ throw new SealError("seal must be a JSON object");
405
+ }
406
+ if (obj.kind !== SEAL_KIND) {
407
+ throw new SealError(
408
+ `not a trustledger reconciliation seal (kind: ${JSON.stringify(obj.kind)}; expected ` +
409
+ `${JSON.stringify(SEAL_KIND)})`
410
+ );
411
+ }
412
+ if (!SUPPORTED_SEAL_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
413
+ throw new SealError(
414
+ `unsupported seal schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
415
+ `(this build understands ${JSON.stringify(SUPPORTED_SEAL_SCHEMA_VERSIONS)})`
416
+ );
417
+ }
418
+ if (obj.note !== SEAL_TRUST_NOTE) {
419
+ throw new SealError("seal `note` must be the standing SEAL_TRUST_NOTE (caveat must not drift)");
420
+ }
421
+ if (typeof obj.root !== "string" || !HEX32_RE.test(obj.root)) {
422
+ throw new SealError(
423
+ `seal root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`
424
+ );
425
+ }
426
+
427
+ // verdict — recorded reconcile facts.
428
+ if (!isPlainObject(obj.verdict)) {
429
+ throw new SealError("seal is missing `verdict` { pass, reportDate }");
430
+ }
431
+ if (typeof obj.verdict.pass !== "boolean") {
432
+ throw new SealError("seal verdict.pass must be a boolean");
433
+ }
434
+ if (!DATE_RE.test(String(obj.verdict.reportDate || ""))) {
435
+ throw new SealError('seal verdict.reportDate must be a "YYYY-MM-DD" string');
436
+ }
437
+ if (!("period" in obj.verdict)) {
438
+ throw new SealError("seal verdict is missing `period` (may be null)");
439
+ }
440
+ if (obj.verdict.period !== null && typeof obj.verdict.period !== "string") {
441
+ throw new SealError("seal verdict.period must be a string or null");
442
+ }
443
+
444
+ if (!Array.isArray(obj.inputs) || obj.inputs.length === 0) {
445
+ throw new SealError("seal `inputs` must be a non-empty array");
446
+ }
447
+ if (!Array.isArray(obj.outputs) || obj.outputs.length === 0) {
448
+ throw new SealError("seal `outputs` must be a non-empty array");
449
+ }
450
+
451
+ // Per-file structural + leaf self-consistency, plus relPath-uniqueness ACROSS the whole set and
452
+ // role-uniqueness within inputs. We collect the flat (relPath, contentHash, leaf) list to build the
453
+ // core-shaped view the core re-derives the root from.
454
+ const seenRelPath = new Set();
455
+ const seenRole = new Set();
456
+ const flatFiles = []; // { relPath, contentHash, leaf }
457
+
458
+ function checkLeafEntry(entry, where, i) {
459
+ if (!isPlainObject(entry)) {
460
+ throw new SealError(`seal ${where}[${i}] must be an object`);
461
+ }
462
+ if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
463
+ throw new SealError(`seal ${where}[${i}].relPath must be a non-empty string`);
464
+ }
465
+ if (seenRelPath.has(entry.relPath)) {
466
+ throw new SealError(
467
+ `seal has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`
468
+ );
469
+ }
470
+ seenRelPath.add(entry.relPath);
471
+ for (const f of ["contentHash", "leaf"]) {
472
+ if (typeof entry[f] !== "string" || !HEX32_RE.test(entry[f])) {
473
+ throw new SealError(
474
+ `seal ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
475
+ );
476
+ }
477
+ }
478
+ const expectedLeaf = pathLeaf(entry.relPath, entry.contentHash);
479
+ if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
480
+ throw new SealError(
481
+ `seal ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
482
+ `(expected ${expectedLeaf}, got ${entry.leaf})`
483
+ );
484
+ }
485
+ flatFiles.push({ relPath: entry.relPath, contentHash: entry.contentHash, leaf: entry.leaf });
486
+ }
487
+
488
+ obj.inputs.forEach((entry, i) => {
489
+ checkLeafEntry(entry, "inputs", i);
490
+ if (!INPUT_ROLES.includes(entry.role)) {
491
+ throw new SealError(
492
+ `seal inputs[${i}].role must be one of ${JSON.stringify(INPUT_ROLES)}, got: ` +
493
+ `${JSON.stringify(entry.role)}`
494
+ );
495
+ }
496
+ if (seenRole.has(entry.role)) {
497
+ throw new SealError(`seal has a duplicate input role: ${JSON.stringify(entry.role)}`);
498
+ }
499
+ seenRole.add(entry.role);
500
+ });
501
+ obj.outputs.forEach((entry, i) => {
502
+ checkLeafEntry(entry, "outputs", i);
503
+ if (entry.role !== undefined && entry.role !== null) {
504
+ throw new SealError(
505
+ `seal outputs[${i}] must not carry a role (roles partition INPUTS only), got: ` +
506
+ `${JSON.stringify(entry.role)}`
507
+ );
508
+ }
509
+ });
510
+
511
+ // fileCount, when present, must agree with the actual entry total.
512
+ const total = obj.inputs.length + obj.outputs.length;
513
+ if (obj.fileCount !== undefined && obj.fileCount !== total) {
514
+ throw new SealError(
515
+ `seal fileCount (${String(obj.fileCount)}) does not match the entry total (${total})`
516
+ );
517
+ }
518
+
519
+ // THE LOAD-BEARING CHECK — delegated to the shared core. Build a core-shaped seal view (flat `files` +
520
+ // the header marker) carrying the SAME root, and let the core re-derive the root from the listed file
521
+ // entries PLUS the synthetic HEADER entry (its content recomputed from THIS seal's verdict + input role
522
+ // bindings via SEAL_CFG.headerContentFor). A seal whose root was edited to mask a changed file is caught
523
+ // there, AND so is one whose verdict (pass/reportDate/period) or input role was edited.
524
+ const coreView = {
525
+ kind: SEAL_KIND,
526
+ schemaVersion: obj.schemaVersion,
527
+ note: SEAL_TRUST_NOTE,
528
+ root: obj.root,
529
+ fileCount: flatFiles.length,
530
+ files: flatFiles,
531
+ header: { relPath: SEAL_HEADER_RELPATH },
532
+ // carry the verdict + inputs so SEAL_CFG.headerContentFor can re-derive the header content
533
+ verdict: obj.verdict,
534
+ inputs: obj.inputs,
535
+ };
536
+ try {
537
+ packetseal.validateSeal(coreView, SEAL_CFG);
538
+ } catch (e) {
539
+ throw _asSealError(e, { rootMessage: true });
540
+ }
541
+
542
+ return obj;
543
+ }
544
+
545
+ // _asSealError(e, opts) — re-frame a core PacketSealError into a SealError with the TrustLedger wording
546
+ // its callers/tests assert on. The core's root-mismatch message is product-agnostic; the TrustLedger
547
+ // surface promises the specific "root does not re-derive from its listed entries + verdict/role header"
548
+ // phrasing, so we substitute it. All other core messages pass through verbatim under SealError.
549
+ function _asSealError(e, opts = {}) {
550
+ if (!(e instanceof packetseal.PacketSealError)) return e;
551
+ if (opts.rootMessage && /root does not re-derive/.test(e.message)) {
552
+ return new SealError(
553
+ "seal root does not re-derive from its listed entries + verdict/role header " +
554
+ "(the seal is internally inconsistent: a file, the verdict, or an input role was edited " +
555
+ "without updating the root)"
556
+ );
557
+ }
558
+ return new SealError(e.message);
559
+ }
560
+
561
+ /**
562
+ * committedLeaves(seal) — the FULL ordered { relPath, contentHash } list the seal's `root` commits
563
+ * to: every listed file (inputs + outputs) PLUS the synthetic verdict/role HEADER entry (recomputed
564
+ * from the seal's own verdict + input role bindings). Validates the seal first. Delegated to the
565
+ * shared core's committedLeaves over a flat core-shaped view. PURE.
566
+ *
567
+ * @param {object} seal a seal (validated here)
568
+ * @returns {{ relPath: string, contentHash: string }[]}
569
+ */
570
+ function committedLeaves(seal) {
571
+ validateSeal(seal);
572
+ const flatFiles = [];
573
+ for (const e of seal.inputs) flatFiles.push({ relPath: e.relPath, contentHash: e.contentHash, leaf: e.leaf });
574
+ for (const e of seal.outputs) flatFiles.push({ relPath: e.relPath, contentHash: e.contentHash, leaf: e.leaf });
575
+ const coreView = {
576
+ kind: SEAL_KIND,
577
+ schemaVersion: seal.schemaVersion,
578
+ note: SEAL_TRUST_NOTE,
579
+ root: seal.root,
580
+ fileCount: flatFiles.length,
581
+ files: flatFiles,
582
+ header: { relPath: SEAL_HEADER_RELPATH },
583
+ verdict: seal.verdict,
584
+ inputs: seal.inputs,
585
+ };
586
+ return packetseal.committedLeaves(coreView, SEAL_CFG);
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // readSeal(text|obj) — parse + validate a seal (JSON string or object). STRICT:
591
+ // a parse error is a SealError (never a raw SyntaxError); a partial/corrupt seal
592
+ // is rejected by validateSeal, never half-accepted.
593
+ // ---------------------------------------------------------------------------
594
+
595
+ function readSeal(input) {
596
+ let obj;
597
+ if (typeof input === "string") {
598
+ try {
599
+ obj = JSON.parse(input);
600
+ } catch (e) {
601
+ throw new SealError(`seal is not valid JSON: ${e.message}`);
602
+ }
603
+ } else if (isPlainObject(input)) {
604
+ obj = input;
605
+ } else {
606
+ throw new SealError("readSeal requires a JSON string or a seal object");
607
+ }
608
+ validateSeal(obj);
609
+ return obj;
610
+ }
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // serializeSeal(seal) — canonical, byte-deterministic serialization: a FIXED
614
+ // top-level + per-entry key order, NO insignificant whitespace, a single
615
+ // trailing newline. Two runs over the same seal produce an identical string.
616
+ // ---------------------------------------------------------------------------
617
+
618
+ function serializeSeal(seal) {
619
+ validateSeal(seal);
620
+ const canonical = {
621
+ kind: seal.kind,
622
+ schemaVersion: seal.schemaVersion,
623
+ note: seal.note,
624
+ root: seal.root,
625
+ fileCount: seal.fileCount,
626
+ verdict: {
627
+ pass: seal.verdict.pass,
628
+ reportDate: seal.verdict.reportDate,
629
+ period: seal.verdict.period,
630
+ },
631
+ inputs: seal.inputs.map((e) => ({
632
+ role: e.role,
633
+ relPath: e.relPath,
634
+ contentHash: e.contentHash,
635
+ leaf: e.leaf,
636
+ })),
637
+ outputs: seal.outputs.map((e) => ({
638
+ relPath: e.relPath,
639
+ contentHash: e.contentHash,
640
+ leaf: e.leaf,
641
+ })),
642
+ };
643
+ return JSON.stringify(canonical) + "\n";
644
+ }
645
+
646
+ // ---------------------------------------------------------------------------
647
+ // verifySeal(seal, files) — the AUTHORITATIVE, PURE verify.
648
+ //
649
+ // Delegates the per-file MATCH/CHANGED/MISSING/UNEXPECTED localization + the root
650
+ // re-derivation (over the supplied files + the verdict/role header) to the shared
651
+ // core, then ADDS the TrustLedger-specific role projection: each finding is tagged
652
+ // with its sealed role, and any path present in BOTH whose SUPPLIED role differs from
653
+ // its SEALED role is surfaced as a `roleMismatch`. The recomputed header binds the
654
+ // SUPPLIED role bindings, so a role swap changes the recomputed root and rootMatches
655
+ // goes false; the roleMismatches list LOCALIZES which paths' roles changed.
656
+ //
657
+ // PURE: no I/O, no key, no network, no clock.
658
+ // ---------------------------------------------------------------------------
659
+
660
+ function verifySeal(seal, files) {
661
+ validateSeal(seal);
662
+ const { inputs, outputs } = _normalizeSuppliedForVerify(files);
663
+
664
+ // The recomputed header uses the seal's recorded verdict + the SUPPLIED role bindings (so a role swap
665
+ // changes the recomputed root). Hand the core the flat supplied entries + that header content.
666
+ const suppliedFlat = [...inputs, ...outputs].map((e) => ({ relPath: e.relPath, bytes: e.bytes }));
667
+ const headerContent = _headerBytes(seal.verdict, inputs);
668
+
669
+ // Build the flat core-shaped seal view so the core can verify per-file + root against it.
670
+ const coreSeal = {
671
+ kind: SEAL_KIND,
672
+ schemaVersion: seal.schemaVersion,
673
+ note: SEAL_TRUST_NOTE,
674
+ root: seal.root,
675
+ fileCount: seal.inputs.length + seal.outputs.length,
676
+ files: [...seal.inputs, ...seal.outputs].map((e) => ({
677
+ relPath: e.relPath,
678
+ contentHash: e.contentHash,
679
+ leaf: e.leaf,
680
+ })),
681
+ header: { relPath: SEAL_HEADER_RELPATH },
682
+ verdict: seal.verdict,
683
+ inputs: seal.inputs,
684
+ };
685
+
686
+ let core;
687
+ try {
688
+ core = packetseal.verifySeal(coreSeal, { entries: suppliedFlat }, SEAL_CFG, { headerContent });
689
+ } catch (e) {
690
+ throw _asSealError(e, { rootMessage: true });
691
+ }
692
+
693
+ // ----- TrustLedger role projection over the core's flat findings. -----
694
+ // sealed relPath -> role; supplied relPath -> role.
695
+ const sealedRole = new Map();
696
+ for (const e of seal.inputs) sealedRole.set(e.relPath, e.role);
697
+ for (const e of seal.outputs) sealedRole.set(e.relPath, null);
698
+ const suppliedRole = new Map();
699
+ for (const e of [...inputs, ...outputs]) suppliedRole.set(e.relPath, e.role);
700
+
701
+ const matched = core.matched.map((m) => ({
702
+ relPath: m.relPath,
703
+ role: sealedRole.get(m.relPath) == null ? null : sealedRole.get(m.relPath),
704
+ contentHash: m.contentHash,
705
+ }));
706
+ const changed = core.changed.map((c) => ({
707
+ relPath: c.relPath,
708
+ role: sealedRole.get(c.relPath) == null ? null : sealedRole.get(c.relPath),
709
+ expectedContentHash: c.expectedContentHash,
710
+ actualContentHash: c.actualContentHash,
711
+ }));
712
+ const missing = core.missing.map((m) => ({
713
+ relPath: m.relPath,
714
+ role: sealedRole.get(m.relPath) == null ? null : sealedRole.get(m.relPath),
715
+ }));
716
+ const unexpected = core.unexpected.map((u) => ({
717
+ relPath: u.relPath,
718
+ role: suppliedRole.get(u.relPath) == null ? null : suppliedRole.get(u.relPath),
719
+ contentHash: u.contentHash,
720
+ }));
721
+
722
+ // Role mismatch: a path present in BOTH whose SUPPLIED role differs from its SEALED role.
723
+ const roleMismatches = [];
724
+ for (const [relPath, sRole] of sealedRole) {
725
+ if (!suppliedRole.has(relPath)) continue;
726
+ const supR = suppliedRole.get(relPath) == null ? null : suppliedRole.get(relPath);
727
+ if (supR !== sRole) {
728
+ roleMismatches.push({ relPath, sealedRole: sRole, suppliedRole: supR });
729
+ }
730
+ }
731
+ const byRel = (a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0);
732
+ roleMismatches.sort(byRel);
733
+
734
+ const accepted = core.accepted && roleMismatches.length === 0;
735
+
736
+ return {
737
+ verdict: accepted ? "ACCEPTED" : "REJECTED",
738
+ accepted,
739
+ sealedRoot: seal.root,
740
+ recomputedRoot: core.recomputedRoot,
741
+ rootMatches: core.rootMatches,
742
+ counts: {
743
+ matched: matched.length,
744
+ changed: changed.length,
745
+ missing: missing.length,
746
+ unexpected: unexpected.length,
747
+ roleMismatched: roleMismatches.length,
748
+ },
749
+ matched,
750
+ changed,
751
+ missing,
752
+ unexpected,
753
+ roleMismatches,
754
+ };
755
+ }
756
+
757
+ // ---------------------------------------------------------------------------
758
+ // SIGNED-attestation WRAP (optional). The seal MAY be wrapped by the EXISTING
759
+ // `cli/core/attestation.js` envelope so a human can vouch for it via the SAME
760
+ // shared signing path — NO new scheme. The seal's CANONICAL bytes (serializeSeal)
761
+ // become the attestation payload. Unchanged by T-30.2.
762
+ //
763
+ // The signature proves WHO vouched for the seal — still NOT a trusted timestamp
764
+ // (P-3) and still NOT a legal opinion (the CPA review governs).
765
+ // ---------------------------------------------------------------------------
766
+
767
+ const coreAttestation = require("../cli/core/attestation");
768
+
769
+ const SIGNED_SEAL_KIND = "trustledger.reconcile-seal-signed";
770
+ const SIGNED_SEAL_SCHEMA_VERSION = 1;
771
+ const SUPPORTED_SIGNED_SEAL_SCHEMA_VERSIONS = Object.freeze([1]);
772
+
773
+ const SIGNED_SEAL_TRUST_NOTE =
774
+ "This is a SIGNED reconciliation-seal container: it WRAPS (never edits) the EXACT canonical seal " +
775
+ "bytes in `attestation` and attaches a detached EIP-191 signature. It asserts the holder of the " +
776
+ "`signer` key vouched for THIS sealed packet (the embedded root + verdict) at signing time. It does " +
777
+ "NOT prove a timestamp (no \"sealed since date T\" — still the human trust-root P-3) and does NOT " +
778
+ "validate the legal MEANING of the reconciliation (the CPA review governs). Every caveat of the " +
779
+ "embedded seal applies. " +
780
+ SEAL_TRUST_NOTE;
781
+
782
+ // The attestation core re-validates and re-serializes the EMBEDDED payload through these, enforcing
783
+ // the wrap-don't-edit invariant. `validateUnsigned` must reject anything that is not a sound seal;
784
+ // `serializeUnsigned` must emit the EXACT canonical bytes that were signed.
785
+ const SIGNED_SEAL_CFG = Object.freeze({
786
+ kind: SIGNED_SEAL_KIND,
787
+ schemaVersion: SIGNED_SEAL_SCHEMA_VERSION,
788
+ supportedSchemaVersions: SUPPORTED_SIGNED_SEAL_SCHEMA_VERSIONS,
789
+ note: SIGNED_SEAL_TRUST_NOTE,
790
+ label: "signed reconciliation seal",
791
+ validateUnsigned: validateSeal,
792
+ serializeUnsigned: serializeSeal,
793
+ });
794
+
795
+ /** Strictly validate a parsed SIGNED-seal container — thin wrapper over the shared core. */
796
+ function validateSignedSeal(obj) {
797
+ return coreAttestation.validateSignedAttestation(obj, SIGNED_SEAL_CFG);
798
+ }
799
+
800
+ /** Serialize a SIGNED-seal container to its canonical bytes — thin wrapper over the shared core. */
801
+ function serializeSignedSeal(container) {
802
+ return coreAttestation.serializeSignedAttestation(container, SIGNED_SEAL_CFG);
803
+ }
804
+
805
+ /** Recover the signing address from a SIGNED-seal container — the shared, PURE core recovery. */
806
+ function recoverSigner(container) {
807
+ return coreAttestation.recoverSigner(container);
808
+ }
809
+
810
+ /**
811
+ * Verify a SIGNED-seal container OFFLINE — thin wrapper over the shared core verifier. Optionally pins
812
+ * the expected signer and/or binds the embedded payload to a caller-recomputed canonical seal string.
813
+ * @param {object} params { container, [expectedSigner], [expectedCanonical] }
814
+ */
815
+ function verifySignedSeal(params) {
816
+ return coreAttestation.verifySignedAttestation(params);
817
+ }
818
+
819
+ /**
820
+ * Sign a validated seal with a caller-supplied ethers signer-like object and WRAP it into a validated
821
+ * SIGNED-seal container — the SAME shared signing path the rest of the family uses (no key handling
822
+ * here; the key lives only inside the signer). The container ROUND-TRIPS by construction:
823
+ * verifySignedSeal recovers exactly this signer over exactly serializeSeal(seal).
824
+ * @param {object} seal a validated seal (from buildSeal/readSeal)
825
+ * @param {object} signer an ethers signer-like object: async getAddress() + signMessage()
826
+ * @returns {Promise<object>} the validated signed-seal container
827
+ */
828
+ async function signSealWith(seal, signer) {
829
+ return coreAttestation.signAttestation({ attestation: seal, signer }, SIGNED_SEAL_CFG);
830
+ }
831
+
832
+ module.exports = {
833
+ SEAL_KIND,
834
+ SEAL_SCHEMA_VERSION,
835
+ SUPPORTED_SEAL_SCHEMA_VERSIONS,
836
+ SEAL_TRUST_NOTE,
837
+ SEAL_HEADER_RELPATH,
838
+ INPUT_ROLES,
839
+ SealError,
840
+ buildSeal,
841
+ validateSeal,
842
+ readSeal,
843
+ serializeSeal,
844
+ verifySeal,
845
+ committedLeaves,
846
+ // optional signed-attestation wrap (shared core)
847
+ SIGNED_SEAL_CFG,
848
+ SIGNED_SEAL_TRUST_NOTE,
849
+ validateSignedSeal,
850
+ serializeSignedSeal,
851
+ recoverSigner,
852
+ verifySignedSeal,
853
+ signSealWith,
854
+ };