verifyhash 0.1.1 → 0.1.2

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.
@@ -1673,6 +1673,10 @@ __modules["verify-vh"] = function (module, exports, __require) {
1673
1673
  const fs = require("fs");
1674
1674
  const os = require("os");
1675
1675
  const path = require("path");
1676
+ // Node CORE sha256 (no npm dependency — the same zero-install class as fs/path; the bundle already
1677
+ // allows `crypto` for its embedded --self-attest). Used ONLY by the T-70.4 anchored-receipt section
1678
+ // below (the dataset/parcel attestation digest legs), which lives OUTSIDE the pure engine block.
1679
+ const nodeCrypto = require("crypto");
1676
1680
 
1677
1681
  const merkle = __require("merkle");
1678
1682
  const canonical = __require("canonical");
@@ -2969,6 +2973,934 @@ function verifyArtifactFromBytes(params) {
2969
2973
 
2970
2974
  // ============================= END VERIFY-VH PURE ENGINE (T-66.1) =============================
2971
2975
 
2976
+ // ===================================================================================================
2977
+ // ANCHORED-RECEIPT OFFLINE BINDING VERIFY (T-70.4) — `verify-vh <receipt> --anchored-artifact <seal>`.
2978
+ //
2979
+ // WHY THIS EXISTS
2980
+ // `vh anchor-artifact` (EPIC-70) emits a canonical `vh-anchored-receipt@1` container binding ONE
2981
+ // sealed artifact's digest to an on-chain registry record. Its OFFLINE binding leg is pure hashing —
2982
+ // but until T-70.4 it ran ONLY through the producer `cli/` stack (which loads `ethers` at module
2983
+ // load), so the family's zero-install "verify without the producer's stack" promise did not reach
2984
+ // the receipt. This section closes that gap: it is an INDEPENDENT, dependency-free port of the
2985
+ // producer core `cli/core/anchor-binding.js` — the receipt container validation, the CLOSED
2986
+ // six-kind digest table, and the binding verdict — written entirely against the verifier's OWN
2987
+ // primitives (lib/merkle keccak, lib/canonical, Node-core sha256). NO `ethers`, NO `cli/` import.
2988
+ //
2989
+ // WHAT IT CHECKS (and what it does NOT)
2990
+ // OFFLINE binding leg ONLY: the receipt is validated STRICTLY (unknown/missing fields, a drifted
2991
+ // trust note, malformed chain facts — each a named `bad-receipt`), the artifact's ONE canonical
2992
+ // digest is RECOMPUTED through the SAME closed kind table the producer uses (each leg re-validating
2993
+ // the artifact through a strict port of its shipped validator first), and the full
2994
+ // { kind, digest, how } triple must match — `kind-mismatch` / `digest-mismatch` / `how-mismatch`
2995
+ // are the specific named rejects, exactly the producer's verdict vocabulary. The receipt's `chain`
2996
+ // facts remain the ANCHORER'S CLAIM: re-checking them against the chain needs a chain endpoint by
2997
+ // definition and stays with the producer cli (`vh verify-anchored --rpc --contract`).
2998
+ //
2999
+ // PARITY DISCIPLINE (pinned by test/verifier.standalone.test.js)
3000
+ // Every wire-format constant here (the receipt kind, the verbatim ANCHOR_TRUST_NOTE, the reason
3001
+ // codes, the closed kind list, the per-kind derivation-rule `how` strings) MUST equal the producer
3002
+ // core's byte-for-byte, and the verdicts on identical inputs MUST match the producer's — the test
3003
+ // asserts both mechanically, so neither side can drift alone. TOTAL: hostile input yields a named
3004
+ // { ok:false, reason, field?, detail? }, never a throw.
3005
+ // ===================================================================================================
3006
+
3007
+ // The container kind + the standing trust note, VERBATIM the producer's (cli/core/anchor-binding.js).
3008
+ const ANCHORED_RECEIPT_KIND = "vh-anchored-receipt@1";
3009
+
3010
+ const ANCHOR_TRUST_NOTE =
3011
+ "This anchored receipt binds the artifact digest above to an on-chain registry record. A receipt " +
3012
+ "from a LOCAL dev chain proves MECHANISM only and is worth NOTHING publicly until a human deploys " +
3013
+ "the registry (STRATEGY.md P-2). On a public chain it proves ONLY that an on-chain record binds " +
3014
+ "this exact digest at a block whose timestamp BOUNDS existence — as trustworthy as the chain + " +
3015
+ "YOUR pinned contract address — NOT the artifact's truth, NOT faithful recording, NOT attribution " +
3016
+ "beyond the anchoring key. The `chain` facts in this receipt are the anchorer's claim until " +
3017
+ "re-checked against the chain (`vh verify-anchored --rpc`).";
3018
+
3019
+ // The stable, named reason codes — the producer's verdict contract, byte-for-byte.
3020
+ const ANCHOR_REASONS = Object.freeze({
3021
+ NOT_AN_OBJECT: "not-an-object",
3022
+ UNKNOWN_KIND: "unknown-kind",
3023
+ EVIDENCE_SEAL_INVALID: "evidence-seal-invalid",
3024
+ AGENT_PACKET_INVALID: "agent-packet-invalid",
3025
+ JOURNAL_TREE_HEAD_INVALID: "journal-tree-head-invalid",
3026
+ TRUSTLEDGER_SEAL_INVALID: "trustledger-seal-invalid",
3027
+ DATASET_ATTESTATION_INVALID: "dataset-attestation-invalid",
3028
+ PARCEL_ATTESTATION_INVALID: "parcel-attestation-invalid",
3029
+ BAD_ARGS: "bad-args",
3030
+ BAD_DIGEST: "bad-digest",
3031
+ BAD_HOW: "bad-how",
3032
+ BAD_LABEL: "bad-label",
3033
+ BAD_CHAIN: "bad-chain",
3034
+ BAD_RECEIPT: "bad-receipt",
3035
+ DIGEST_MISMATCH: "digest-mismatch",
3036
+ KIND_MISMATCH: "kind-mismatch",
3037
+ HOW_MISMATCH: "how-mismatch",
3038
+ });
3039
+
3040
+ // The two closed-table kinds this verifier did not already name (the other four reuse KINDS above).
3041
+ const ANCHOR_JOURNAL_TREE_HEAD_KIND = "vh.journal-tree-head";
3042
+ const ANCHOR_PARCEL_ATTESTATION_KIND = "verifyhash.parcel-attestation";
3043
+
3044
+ // The CLOSED, frozen kind table — same six kinds, same order as the producer core.
3045
+ const ANCHOR_ARTIFACT_KINDS = Object.freeze([
3046
+ KINDS.EVIDENCE_SEAL, // "vh.evidence-seal"
3047
+ KINDS.AGENT_PACKET, // "vh.agent-session-packet"
3048
+ ANCHOR_JOURNAL_TREE_HEAD_KIND, // "vh.journal-tree-head"
3049
+ KINDS.TRUST_SEAL, // "trustledger.reconcile-seal"
3050
+ KINDS.DATASET_ATTESTATION, // "verifyhash.dataset-attestation"
3051
+ ANCHOR_PARCEL_ATTESTATION_KIND, // "verifyhash.parcel-attestation"
3052
+ ]);
3053
+
3054
+ // Canonical-case wire shapes (the receipt is canonical LOWERCASE; artifacts may carry mixed-case hex
3055
+ // exactly where the producer validators accept it).
3056
+ const ANCHOR_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
3057
+ const ANCHOR_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
3058
+ const ANCHOR_CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
3059
+ const ANCHOR_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
3060
+
3061
+ function anchorIsPlainObject(v) {
3062
+ return v != null && typeof v === "object" && !Array.isArray(v);
3063
+ }
3064
+
3065
+ // The per-kind derivation rules (`how`) — VERBATIM the producer's HOW_FIXED table. These are WIRE
3066
+ // FORMAT (bound into every receipt), so they name the producer's files even though THIS verifier
3067
+ // re-derives the digest with its own independent code: the rule describes the derivation, and the
3068
+ // parity test pins these strings against the producer core byte-for-byte.
3069
+ const ANCHOR_HOW_FIXED = Object.freeze({
3070
+ [KINDS.EVIDENCE_SEAL]:
3071
+ "digest = the evidence packet's `root` (sorted-pair Merkle root over its path-bound file leaves), " +
3072
+ "re-derived by cli/evidence.js readSeal before extraction",
3073
+ [KINDS.AGENT_PACKET]:
3074
+ "digest = the agent-session packet's verified head `root` (RFC-6962 ordered Merkle root over the " +
3075
+ "event leaves), re-derived by cli/agent.js verifyPacket before extraction",
3076
+ [KINDS.TRUST_SEAL]:
3077
+ "digest = the TrustLedger sealfile's `root` (Merkle root over its committed input/output leaves + " +
3078
+ "verdict header), re-derived by trustledger/seal.js readSeal before extraction",
3079
+ [KINDS.DATASET_ATTESTATION]:
3080
+ "digest = 0x + sha256 over the canonical UNSIGNED dataset-attestation bytes, exactly as " +
3081
+ "`vh dataset timestamp-request` computes it (cli/core/timestamp.js sha256Hex)",
3082
+ [ANCHOR_PARCEL_ATTESTATION_KIND]:
3083
+ "digest = 0x + sha256 over the canonical UNSIGNED parcel-attestation bytes, exactly as " +
3084
+ "`vh parcel timestamp-request` computes it (cli/core/timestamp.js sha256Hex)",
3085
+ });
3086
+
3087
+ function anchorJournalHow(size) {
3088
+ return (
3089
+ `digest = the journal tree head \`root\` (RFC-6962 ordered Merkle root, cli/journal-log.js ` +
3090
+ `treeHead) over ${size} entries; the head size is bound into this derivation rule`
3091
+ );
3092
+ }
3093
+
3094
+ const ANCHOR_JOURNAL_HOW_RE =
3095
+ /^digest = the journal tree head `root` \(RFC-6962 ordered Merkle root, cli\/journal-log\.js treeHead\) over (0|[1-9][0-9]*) entries; the head size is bound into this derivation rule$/;
3096
+
3097
+ function anchorHowValidFor(kind, how) {
3098
+ if (typeof how !== "string") return false;
3099
+ if (kind === ANCHOR_JOURNAL_TREE_HEAD_KIND) {
3100
+ const m = ANCHOR_JOURNAL_HOW_RE.exec(how);
3101
+ return m !== null && Number.isSafeInteger(Number(m[1]));
3102
+ }
3103
+ return how === ANCHOR_HOW_FIXED[kind];
3104
+ }
3105
+
3106
+ function anchorOk(digest, kind, how) {
3107
+ return { ok: true, digest, kind, how };
3108
+ }
3109
+ function anchorNo(reason, detail) {
3110
+ return detail === undefined ? { ok: false, reason } : { ok: false, reason, detail };
3111
+ }
3112
+
3113
+ // ---------------------------------------------------------------------------------------------------
3114
+ // The per-kind STRICT validators + digest extraction — independent ports of the artifacts' shipped
3115
+ // validators (the messages mirror the producers' so the named verdict a counterparty reads is the
3116
+ // same either way). Each leg is TOTAL: a defect is a named reject, never a throw out of this section.
3117
+ // ---------------------------------------------------------------------------------------------------
3118
+
3119
+ // vh.evidence-seal — a strict port of cli/core/packetseal.js validateSeal under the evidence config
3120
+ // (kind/schemaVersion/note pinned, per-entry leaf self-consistency, NO header, and the LOAD-BEARING
3121
+ // root re-derivation from the seal's OWN (relPath, contentHash) leaves via the verifier's merkle lib).
3122
+ const ANCHOR_EVIDENCE_TRUST_NOTE =
3123
+ "This evidence seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp. Its Merkle " +
3124
+ "`root` commits to the full set of (relPath, content) pairs in the directory: any edit, rename, add, " +
3125
+ "or remove changes the root, and verify RE-DERIVES the root from the bytes you hold and LOCALIZES the " +
3126
+ "change to the exact file (MATCH / CHANGED / MISSING / UNEXPECTED). It does NOT prove WHEN the sealing " +
3127
+ 'happened ("sealed at T" rides the human-owned signing/timestamp trust-root, STRATEGY.md P-3) and it ' +
3128
+ "is NOT a legal opinion. The packet is an UNTRUSTED transport container: verify never trusts the " +
3129
+ "packet's own stored hashes.";
3130
+ const ANCHOR_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([1]);
3131
+
3132
+ // Shared strict per-entry + root checks for the two packetseal-family legs. `label` carries the
3133
+ // product wording; `headerLeaf` (when non-null) is folded into the root as the reserved header entry.
3134
+ function anchorCheckSealEntries(entries, label, where, seenRelPath, flat, headerRelPath) {
3135
+ entries.forEach((entry, i) => {
3136
+ if (!anchorIsPlainObject(entry)) {
3137
+ throw new Error(`${label} ${where}[${i}] must be an object`);
3138
+ }
3139
+ if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
3140
+ throw new Error(`${label} ${where}[${i}].relPath must be a non-empty string`);
3141
+ }
3142
+ if (headerRelPath !== null && entry.relPath === headerRelPath) {
3143
+ throw new Error(
3144
+ `${label} ${where}[${i}].relPath ${JSON.stringify(entry.relPath)} is reserved for the seal header`
3145
+ );
3146
+ }
3147
+ if (seenRelPath.has(entry.relPath)) {
3148
+ throw new Error(`${label} has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`);
3149
+ }
3150
+ seenRelPath.add(entry.relPath);
3151
+ for (const f of ["contentHash", "leaf"]) {
3152
+ if (typeof entry[f] !== "string" || !merkle.HEX32_RE.test(entry[f])) {
3153
+ throw new Error(
3154
+ `${label} ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
3155
+ );
3156
+ }
3157
+ }
3158
+ const expectedLeaf = merkle.pathLeaf(entry.relPath, entry.contentHash);
3159
+ if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
3160
+ throw new Error(
3161
+ `${label} ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
3162
+ `(expected ${expectedLeaf}, got ${entry.leaf})`
3163
+ );
3164
+ }
3165
+ flat.push({ relPath: entry.relPath, contentHash: entry.contentHash });
3166
+ });
3167
+ }
3168
+
3169
+ function anchorValidateEvidenceSeal(obj) {
3170
+ const label = "evidence seal";
3171
+ if (!anchorIsPlainObject(obj)) throw new Error(`${label} must be a JSON object`);
3172
+ if (obj.kind !== KINDS.EVIDENCE_SEAL) {
3173
+ throw new Error(`not a ${label} (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(KINDS.EVIDENCE_SEAL)})`);
3174
+ }
3175
+ if (!ANCHOR_EVIDENCE_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
3176
+ throw new Error(
3177
+ `unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
3178
+ `(this build understands ${JSON.stringify(ANCHOR_EVIDENCE_SCHEMA_VERSIONS)})`
3179
+ );
3180
+ }
3181
+ if (obj.note !== ANCHOR_EVIDENCE_TRUST_NOTE) {
3182
+ throw new Error(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
3183
+ }
3184
+ if (typeof obj.root !== "string" || !merkle.HEX32_RE.test(obj.root)) {
3185
+ throw new Error(`${label} root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`);
3186
+ }
3187
+ if (!Array.isArray(obj.files) || obj.files.length === 0) {
3188
+ throw new Error(`${label} \`files\` must be a non-empty array`);
3189
+ }
3190
+ const flat = [];
3191
+ anchorCheckSealEntries(obj.files, label, "files", new Set(), flat, null);
3192
+ if (obj.fileCount !== undefined && obj.fileCount !== obj.files.length) {
3193
+ throw new Error(`${label} fileCount (${String(obj.fileCount)}) does not match the files length (${obj.files.length})`);
3194
+ }
3195
+ if (obj.header !== undefined) {
3196
+ throw new Error(`${label} carries a header but its config declares none`);
3197
+ }
3198
+ const rederived = merkle.rootFromFlat(flat);
3199
+ if (rederived.toLowerCase() !== obj.root.toLowerCase()) {
3200
+ throw new Error(
3201
+ `${label} root does not re-derive from its listed entries ` +
3202
+ `(expected ${rederived}, got ${obj.root}) — the seal is internally inconsistent ` +
3203
+ "(a file was edited without updating the root)"
3204
+ );
3205
+ }
3206
+ return obj;
3207
+ }
3208
+
3209
+ function anchorEvidenceDigest(artifact) {
3210
+ try {
3211
+ anchorValidateEvidenceSeal(artifact);
3212
+ } catch (e) {
3213
+ return anchorNo(ANCHOR_REASONS.EVIDENCE_SEAL_INVALID, e && e.message ? e.message : String(e));
3214
+ }
3215
+ return anchorOk(artifact.root.toLowerCase(), KINDS.EVIDENCE_SEAL, ANCHOR_HOW_FIXED[KINDS.EVIDENCE_SEAL]);
3216
+ }
3217
+
3218
+ // vh.agent-session-packet — REUSES this verifier's OWN independent agent engine verbatim: the strict
3219
+ // packet-structure validation + the authoritative per-event/leaf/root/counts recompute, PLUS (when a
3220
+ // headAttestation is present) the head-binding and signature-genuineness checks — the exact facts the
3221
+ // producer's `agent.verifyPacket` gates the digest on (a vendor pin is not part of digest extraction).
3222
+ function anchorAgentDigest(artifact) {
3223
+ let structure;
3224
+ try {
3225
+ structure = validateAgentPacketStructure(artifact);
3226
+ } catch (e) {
3227
+ return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, e && e.message ? e.message : String(e));
3228
+ }
3229
+ const fileResult = verifyAgentSeal(artifact);
3230
+ const agent = fileResult.agent;
3231
+ const seqOf = () => (agent.seq !== null && agent.seq !== undefined ? ` at seq ${agent.seq}` : "");
3232
+ if (!fileResult.filesOk) {
3233
+ const reason = agent.reason || fileResult.reasonKind || "REJECTED";
3234
+ return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, `packet verify REJECTED: ${reason}${seqOf()}`);
3235
+ }
3236
+ if (artifact.headAttestation !== undefined) {
3237
+ const embedded = structure.signedHead.embeddedHead;
3238
+ const bound =
3239
+ embedded.size === agent.recomputedHead.size && embedded.root === agent.recomputedHead.root;
3240
+ if (!bound) {
3241
+ return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, "packet verify REJECTED: HEAD_NOT_BOUND");
3242
+ }
3243
+ const claimed = artifact.headAttestation.signature.signer; // lowercase, structurally enforced
3244
+ const recovered = tryRecover(artifact.headAttestation.attestation, artifact.headAttestation.signature.signature);
3245
+ if (recovered == null || recovered !== claimed) {
3246
+ return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, "packet verify REJECTED: SIGNATURE_FORGED");
3247
+ }
3248
+ }
3249
+ return anchorOk(fileResult.recomputedRoot, KINDS.AGENT_PACKET, ANCHOR_HOW_FIXED[KINDS.AGENT_PACKET]);
3250
+ }
3251
+
3252
+ // vh.journal-tree-head — the bare { size, root } commitment or its kind-tagged twin. The empty-root
3253
+ // constant is re-derived HERE from the family's domain string with the verifier's own keccak (equal
3254
+ // to cli/journal-log.js EMPTY_ROOT — pinned by the parity test).
3255
+ const ANCHOR_JOURNAL_EMPTY_ROOT = merkle.hashBytes(Buffer.from(AGENT_EMPTY_ROOT_DOMAIN, "utf8"));
3256
+
3257
+ function anchorJournalHeadDigest(artifact, tagged) {
3258
+ const allowed = tagged ? ["kind", "size", "root"] : ["size", "root"];
3259
+ for (const k of Object.keys(artifact)) {
3260
+ if (!allowed.includes(k)) {
3261
+ return anchorNo(
3262
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
3263
+ `journal tree head has unknown field: ${JSON.stringify(k)}`
3264
+ );
3265
+ }
3266
+ }
3267
+ if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) {
3268
+ return anchorNo(
3269
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
3270
+ `journal tree head size must be a non-negative integer, got: ${String(artifact.size)}`
3271
+ );
3272
+ }
3273
+ if (typeof artifact.root !== "string" || !ANCHOR_HEX32_LC_RE.test(artifact.root)) {
3274
+ return anchorNo(
3275
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
3276
+ `journal tree head root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(artifact.root)}`
3277
+ );
3278
+ }
3279
+ if (artifact.size === 0 && artifact.root !== ANCHOR_JOURNAL_EMPTY_ROOT) {
3280
+ return anchorNo(
3281
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
3282
+ `an EMPTY journal tree head (size 0) must carry the documented empty root ${ANCHOR_JOURNAL_EMPTY_ROOT}`
3283
+ );
3284
+ }
3285
+ if (artifact.size > 0 && artifact.root === ANCHOR_JOURNAL_EMPTY_ROOT) {
3286
+ return anchorNo(
3287
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
3288
+ "a non-empty journal tree head cannot carry the domain-separated EMPTY root"
3289
+ );
3290
+ }
3291
+ return anchorOk(artifact.root, ANCHOR_JOURNAL_TREE_HEAD_KIND, anchorJournalHow(artifact.size));
3292
+ }
3293
+
3294
+ // trustledger.reconcile-seal — a strict port of trustledger/seal.js validateSeal: the verdict/role/
3295
+ // inputs/outputs checks, per-entry leaf self-consistency, and the LOAD-BEARING root re-derivation from
3296
+ // the seal's OWN leaves PLUS the synthetic verdict/role HEADER leaf (content re-derived from the
3297
+ // seal's recorded verdict + input role bindings via the verifier's own lib/canonical port).
3298
+ const ANCHOR_TRUST_SEAL_NOTE =
3299
+ "This reconciliation seal is TAMPER-EVIDENT, not a trusted timestamp and not a legal opinion. Its " +
3300
+ "Merkle `root` commits to the full set of (relPath, content) pairs across the source inputs AND " +
3301
+ "every emitted packet file, PLUS a reserved HEADER leaf binding the recorded verdict " +
3302
+ "(pass/reportDate/period) and each input's logical role: any edit, rename, add, or remove of a " +
3303
+ "file — or any edit of the verdict/date/period or swap of an input role — changes the root, and " +
3304
+ "verifySeal localizes a file change to the exact file and a verdict/role change to the header. It " +
3305
+ "does NOT prove WHEN the sealing actually happened (the bound reportDate cannot be edited " +
3306
+ "undetected, but a self-asserted date still rides the human trust-root P-3 — standing up a real " +
3307
+ "signing key or timestamp anchor is needs-human) and it does NOT validate the legal MEANING of " +
3308
+ "the reconciliation (the CPA review still governs). The seal is an UNTRUSTED transport container: " +
3309
+ "verifySeal RE-DERIVES the root from the bytes you supply — it never trusts the seal's own hashes.";
3310
+ const ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS = Object.freeze([1]);
3311
+ const ANCHOR_TRUST_SEAL_INPUT_ROLES = Object.freeze(["bank", "book", "rentroll"]);
3312
+ const ANCHOR_TRUST_SEAL_CORE_LABEL = "trustledger reconciliation seal";
3313
+
3314
+ function anchorValidateTrustSeal(obj) {
3315
+ if (!anchorIsPlainObject(obj)) throw new Error("seal must be a JSON object");
3316
+ if (obj.kind !== KINDS.TRUST_SEAL) {
3317
+ throw new Error(
3318
+ `not a trustledger reconciliation seal (kind: ${JSON.stringify(obj.kind)}; expected ` +
3319
+ `${JSON.stringify(KINDS.TRUST_SEAL)})`
3320
+ );
3321
+ }
3322
+ if (!ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
3323
+ throw new Error(
3324
+ `unsupported seal schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
3325
+ `(this build understands ${JSON.stringify(ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS)})`
3326
+ );
3327
+ }
3328
+ if (obj.note !== ANCHOR_TRUST_SEAL_NOTE) {
3329
+ throw new Error("seal `note` must be the standing SEAL_TRUST_NOTE (caveat must not drift)");
3330
+ }
3331
+ if (typeof obj.root !== "string" || !merkle.HEX32_RE.test(obj.root)) {
3332
+ throw new Error(`seal root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`);
3333
+ }
3334
+ if (!anchorIsPlainObject(obj.verdict)) {
3335
+ throw new Error("seal is missing `verdict` { pass, reportDate }");
3336
+ }
3337
+ if (typeof obj.verdict.pass !== "boolean") {
3338
+ throw new Error("seal verdict.pass must be a boolean");
3339
+ }
3340
+ if (!ANCHOR_DATE_RE.test(String(obj.verdict.reportDate || ""))) {
3341
+ throw new Error('seal verdict.reportDate must be a "YYYY-MM-DD" string');
3342
+ }
3343
+ if (!("period" in obj.verdict)) {
3344
+ throw new Error("seal verdict is missing `period` (may be null)");
3345
+ }
3346
+ if (obj.verdict.period !== null && typeof obj.verdict.period !== "string") {
3347
+ throw new Error("seal verdict.period must be a string or null");
3348
+ }
3349
+ if (!Array.isArray(obj.inputs) || obj.inputs.length === 0) {
3350
+ throw new Error("seal `inputs` must be a non-empty array");
3351
+ }
3352
+ if (!Array.isArray(obj.outputs) || obj.outputs.length === 0) {
3353
+ throw new Error("seal `outputs` must be a non-empty array");
3354
+ }
3355
+
3356
+ const seenRelPath = new Set();
3357
+ const seenRole = new Set();
3358
+ const flat = [];
3359
+ // Per-entry checks use the trustledger wording (`seal inputs[0]...`); the reserved-header check uses
3360
+ // the core-config label, exactly as the producer's core-delegated view reports it.
3361
+ const checkEntries = (entries, where) => {
3362
+ entries.forEach((entry, i) => {
3363
+ if (!anchorIsPlainObject(entry)) throw new Error(`seal ${where}[${i}] must be an object`);
3364
+ if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
3365
+ throw new Error(`seal ${where}[${i}].relPath must be a non-empty string`);
3366
+ }
3367
+ if (entry.relPath === canonical.TRUST_SEAL_HEADER_RELPATH) {
3368
+ throw new Error(
3369
+ `${ANCHOR_TRUST_SEAL_CORE_LABEL} files[${flat.length}].relPath ` +
3370
+ `${JSON.stringify(entry.relPath)} is reserved for the seal header`
3371
+ );
3372
+ }
3373
+ if (seenRelPath.has(entry.relPath)) {
3374
+ throw new Error(`seal has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`);
3375
+ }
3376
+ seenRelPath.add(entry.relPath);
3377
+ for (const f of ["contentHash", "leaf"]) {
3378
+ if (typeof entry[f] !== "string" || !merkle.HEX32_RE.test(entry[f])) {
3379
+ throw new Error(
3380
+ `seal ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
3381
+ );
3382
+ }
3383
+ }
3384
+ const expectedLeaf = merkle.pathLeaf(entry.relPath, entry.contentHash);
3385
+ if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
3386
+ throw new Error(
3387
+ `seal ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
3388
+ `(expected ${expectedLeaf}, got ${entry.leaf})`
3389
+ );
3390
+ }
3391
+ flat.push({ relPath: entry.relPath, contentHash: entry.contentHash });
3392
+ });
3393
+ };
3394
+ checkEntries(obj.inputs, "inputs");
3395
+ obj.inputs.forEach((entry, i) => {
3396
+ if (!ANCHOR_TRUST_SEAL_INPUT_ROLES.includes(entry.role)) {
3397
+ throw new Error(
3398
+ `seal inputs[${i}].role must be one of ${JSON.stringify(ANCHOR_TRUST_SEAL_INPUT_ROLES)}, got: ` +
3399
+ `${JSON.stringify(entry.role)}`
3400
+ );
3401
+ }
3402
+ if (seenRole.has(entry.role)) {
3403
+ throw new Error(`seal has a duplicate input role: ${JSON.stringify(entry.role)}`);
3404
+ }
3405
+ seenRole.add(entry.role);
3406
+ });
3407
+ checkEntries(obj.outputs, "outputs");
3408
+ obj.outputs.forEach((entry, i) => {
3409
+ if (entry.role !== undefined && entry.role !== null) {
3410
+ throw new Error(
3411
+ `seal outputs[${i}] must not carry a role (roles partition INPUTS only), got: ` +
3412
+ `${JSON.stringify(entry.role)}`
3413
+ );
3414
+ }
3415
+ });
3416
+ const total = obj.inputs.length + obj.outputs.length;
3417
+ if (obj.fileCount !== undefined && obj.fileCount !== total) {
3418
+ throw new Error(`seal fileCount (${String(obj.fileCount)}) does not match the entry total (${total})`);
3419
+ }
3420
+
3421
+ // THE LOAD-BEARING CHECK: re-derive the root from the listed leaves PLUS the verdict/role HEADER leaf.
3422
+ const headerBytes = canonical.trustSealHeaderBytes(
3423
+ obj.verdict,
3424
+ obj.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
3425
+ );
3426
+ const committed = [
3427
+ ...flat,
3428
+ { relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
3429
+ ];
3430
+ const rederived = merkle.rootFromFlat(committed);
3431
+ if (rederived.toLowerCase() !== obj.root.toLowerCase()) {
3432
+ throw new Error(
3433
+ "seal root does not re-derive from its listed entries + verdict/role header " +
3434
+ "(the seal is internally inconsistent: a file, the verdict, or an input role was edited " +
3435
+ "without updating the root)"
3436
+ );
3437
+ }
3438
+ return obj;
3439
+ }
3440
+
3441
+ function anchorTrustledgerDigest(artifact) {
3442
+ try {
3443
+ anchorValidateTrustSeal(artifact);
3444
+ } catch (e) {
3445
+ return anchorNo(ANCHOR_REASONS.TRUSTLEDGER_SEAL_INVALID, e && e.message ? e.message : String(e));
3446
+ }
3447
+ return anchorOk(artifact.root.toLowerCase(), KINDS.TRUST_SEAL, ANCHOR_HOW_FIXED[KINDS.TRUST_SEAL]);
3448
+ }
3449
+
3450
+ // verifyhash.dataset-attestation / verifyhash.parcel-attestation — strict ports of the shipped
3451
+ // validators (cli/dataset.js validateAttestation / cli/parcel.js validateParcelAttestation), then the
3452
+ // SAME canonical bytes the producers serialize (via the verifier's own lib/canonical port — the two
3453
+ // attestation shapes share the identical canonical key order), hashed with Node-core sha256. The
3454
+ // closed field set is enforced FIRST, exactly as the producer core does: an unknown key would ride
3455
+ // along unbound by the digest, so it is rejected rather than silently dropped.
3456
+ const ANCHOR_ATTESTATION_FIELDS = Object.freeze([
3457
+ "kind",
3458
+ "schemaVersion",
3459
+ "note",
3460
+ "root",
3461
+ "fileCount",
3462
+ "manifestDigest",
3463
+ "signed",
3464
+ "signature",
3465
+ ]);
3466
+ const ANCHOR_ATTESTATION_SCHEMA_VERSIONS = Object.freeze([1]);
3467
+
3468
+ function anchorValidateAttestation(obj, kind, noun) {
3469
+ if (!anchorIsPlainObject(obj)) throw new Error(`${noun} attestation must be a JSON object`);
3470
+ if (obj.kind !== kind) {
3471
+ throw new Error(
3472
+ `not a verifyhash ${noun} attestation (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(kind)})`
3473
+ );
3474
+ }
3475
+ if (!ANCHOR_ATTESTATION_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
3476
+ throw new Error(
3477
+ `unsupported ${noun} attestation schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
3478
+ `(this build understands ${JSON.stringify(ANCHOR_ATTESTATION_SCHEMA_VERSIONS)})`
3479
+ );
3480
+ }
3481
+ for (const f of ["root", "manifestDigest"]) {
3482
+ if (typeof obj[f] !== "string" || !merkle.HEX32_RE.test(obj[f])) {
3483
+ throw new Error(`${noun} attestation ${f} must be a 0x-prefixed 32-byte hex string, got: ${String(obj[f])}`);
3484
+ }
3485
+ }
3486
+ if (!Number.isInteger(obj.fileCount) || obj.fileCount < 1) {
3487
+ throw new Error(`${noun} attestation fileCount must be a positive integer, got: ${String(obj.fileCount)}`);
3488
+ }
3489
+ if (obj.signed !== false) {
3490
+ throw new Error(
3491
+ `${noun} attestation signed must be false (this build emits/reads only the UNSIGNED payload; ` +
3492
+ `attaching a real signature is the human-owned trust-root, P-3), got: ${String(obj.signed)}`
3493
+ );
3494
+ }
3495
+ if (obj.signature !== null) {
3496
+ throw new Error(`${noun} attestation signature must be null in the UNSIGNED payload, got: ${String(obj.signature)}`);
3497
+ }
3498
+ return obj;
3499
+ }
3500
+
3501
+ function anchorAttestationDigest(artifact, kind, noun, reason) {
3502
+ for (const k of Object.keys(artifact)) {
3503
+ if (!ANCHOR_ATTESTATION_FIELDS.includes(k)) {
3504
+ return anchorNo(reason, `attestation has unknown field ${JSON.stringify(k)} (the canonical bytes would not bind it)`);
3505
+ }
3506
+ }
3507
+ let canonicalBytes;
3508
+ try {
3509
+ anchorValidateAttestation(artifact, kind, noun);
3510
+ // The verifier's own canonical serializer: the SAME fixed key order + trailing newline the
3511
+ // producer emits (dataset and parcel attestations share the identical canonical shape).
3512
+ canonicalBytes = canonical.serializeUnsignedDatasetAttestation(artifact);
3513
+ } catch (e) {
3514
+ return anchorNo(reason, e && e.message ? e.message : String(e));
3515
+ }
3516
+ const digest = "0x" + nodeCrypto.createHash("sha256").update(canonicalBytes, "utf8").digest("hex");
3517
+ return anchorOk(digest, kind, ANCHOR_HOW_FIXED[kind]);
3518
+ }
3519
+
3520
+ /**
3521
+ * Extract the ONE canonical 32-byte digest a chain record binds for `artifact` — the standalone port
3522
+ * of the producer core's artifactDigest, dispatching over the SAME closed kind table. TOTAL.
3523
+ */
3524
+ function anchorArtifactDigest(artifact) {
3525
+ try {
3526
+ if (!anchorIsPlainObject(artifact)) {
3527
+ return anchorNo(ANCHOR_REASONS.NOT_AN_OBJECT, "artifact must be a parsed JSON object");
3528
+ }
3529
+ const kind = artifact.kind;
3530
+ if (kind === undefined) {
3531
+ if ("size" in artifact || "root" in artifact) {
3532
+ return anchorJournalHeadDigest(artifact, false);
3533
+ }
3534
+ return anchorNo(
3535
+ ANCHOR_REASONS.UNKNOWN_KIND,
3536
+ "artifact carries no `kind` and is not a { size, root } journal tree head"
3537
+ );
3538
+ }
3539
+ if (typeof kind !== "string") {
3540
+ return anchorNo(ANCHOR_REASONS.UNKNOWN_KIND, "artifact `kind` must be a string");
3541
+ }
3542
+ switch (kind) {
3543
+ case KINDS.EVIDENCE_SEAL:
3544
+ return anchorEvidenceDigest(artifact);
3545
+ case KINDS.AGENT_PACKET:
3546
+ return anchorAgentDigest(artifact);
3547
+ case ANCHOR_JOURNAL_TREE_HEAD_KIND:
3548
+ return anchorJournalHeadDigest(artifact, true);
3549
+ case KINDS.TRUST_SEAL:
3550
+ return anchorTrustledgerDigest(artifact);
3551
+ case KINDS.DATASET_ATTESTATION:
3552
+ return anchorAttestationDigest(
3553
+ artifact,
3554
+ KINDS.DATASET_ATTESTATION,
3555
+ "dataset",
3556
+ ANCHOR_REASONS.DATASET_ATTESTATION_INVALID
3557
+ );
3558
+ case ANCHOR_PARCEL_ATTESTATION_KIND:
3559
+ return anchorAttestationDigest(
3560
+ artifact,
3561
+ ANCHOR_PARCEL_ATTESTATION_KIND,
3562
+ "parcel",
3563
+ ANCHOR_REASONS.PARCEL_ATTESTATION_INVALID
3564
+ );
3565
+ default:
3566
+ return anchorNo(
3567
+ ANCHOR_REASONS.UNKNOWN_KIND,
3568
+ `unknown artifact kind ${JSON.stringify(kind)} (the closed table: ${ANCHOR_ARTIFACT_KINDS.join(", ")})`
3569
+ );
3570
+ }
3571
+ } catch (e) {
3572
+ return anchorNo(ANCHOR_REASONS.NOT_AN_OBJECT, e && e.message ? e.message : String(e));
3573
+ }
3574
+ }
3575
+
3576
+ // ---------------------------------------------------------------------------------------------------
3577
+ // Receipt validation + the binding verdict — verbatim ports of the producer core's _validateReceipt /
3578
+ // verifyAnchoredReceipt (strict form checks; every deviation a named `bad-receipt` naming the field).
3579
+ // ---------------------------------------------------------------------------------------------------
3580
+
3581
+ const ANCHOR_CHAIN_FIELDS = Object.freeze([
3582
+ "authorBound",
3583
+ "blockNumber",
3584
+ "blockTime",
3585
+ "chainId",
3586
+ "contract",
3587
+ "contributor",
3588
+ "txHash",
3589
+ ]);
3590
+ const ANCHOR_RECEIPT_FIELDS = Object.freeze(["artifactKind", "artifactLabel", "chain", "digest", "how", "kind", "note"]);
3591
+ const ANCHOR_RECEIPT_REQUIRED = Object.freeze(["artifactKind", "chain", "digest", "how", "kind", "note"]);
3592
+
3593
+ function anchorBadReceipt(field, detail) {
3594
+ return { ok: false, reason: ANCHOR_REASONS.BAD_RECEIPT, field, detail };
3595
+ }
3596
+
3597
+ function anchorCheckChain(chain) {
3598
+ if (!anchorIsPlainObject(chain)) {
3599
+ return { ok: false, field: "chain", detail: "chain must be an object of the seven recorded chain facts" };
3600
+ }
3601
+ for (const k of Object.keys(chain)) {
3602
+ if (!ANCHOR_CHAIN_FIELDS.includes(k)) {
3603
+ return { ok: false, field: `chain.${k}`, detail: `chain has unknown field: ${JSON.stringify(k)}` };
3604
+ }
3605
+ }
3606
+ for (const k of ANCHOR_CHAIN_FIELDS) {
3607
+ if (!(k in chain)) {
3608
+ return { ok: false, field: `chain.${k}`, detail: `chain is missing required field: ${JSON.stringify(k)}` };
3609
+ }
3610
+ }
3611
+ if (typeof chain.authorBound !== "boolean") {
3612
+ return { ok: false, field: "chain.authorBound", detail: "authorBound must be a boolean" };
3613
+ }
3614
+ for (const k of ["blockNumber", "blockTime"]) {
3615
+ if (!Number.isSafeInteger(chain[k]) || chain[k] < 0) {
3616
+ return { ok: false, field: `chain.${k}`, detail: `${k} must be a non-negative integer, got: ${String(chain[k])}` };
3617
+ }
3618
+ }
3619
+ if (!Number.isSafeInteger(chain.chainId) || chain.chainId < 1) {
3620
+ return { ok: false, field: "chain.chainId", detail: `chainId must be a positive integer, got: ${String(chain.chainId)}` };
3621
+ }
3622
+ for (const k of ["contract", "contributor"]) {
3623
+ if (typeof chain[k] !== "string" || !ANCHOR_ADDRESS_LC_RE.test(chain[k])) {
3624
+ return {
3625
+ ok: false,
3626
+ field: `chain.${k}`,
3627
+ detail: `${k} must be a LOWERCASE 0x-address (canonical case), got: ${String(chain[k])}`,
3628
+ };
3629
+ }
3630
+ }
3631
+ if (typeof chain.txHash !== "string" || !ANCHOR_HEX32_LC_RE.test(chain.txHash)) {
3632
+ return {
3633
+ ok: false,
3634
+ field: "chain.txHash",
3635
+ detail: `txHash must be a LOWERCASE 0x-bytes32 hex string, got: ${String(chain.txHash)}`,
3636
+ };
3637
+ }
3638
+ return { ok: true };
3639
+ }
3640
+
3641
+ function anchorCanonicalChain(chain) {
3642
+ return {
3643
+ authorBound: chain.authorBound,
3644
+ blockNumber: chain.blockNumber,
3645
+ blockTime: chain.blockTime,
3646
+ chainId: chain.chainId,
3647
+ contract: chain.contract,
3648
+ contributor: chain.contributor,
3649
+ txHash: chain.txHash,
3650
+ };
3651
+ }
3652
+
3653
+ function anchorValidateReceipt(receipt) {
3654
+ if (!anchorIsPlainObject(receipt)) {
3655
+ return anchorBadReceipt("receipt", "receipt must be a parsed JSON object");
3656
+ }
3657
+ for (const k of Object.keys(receipt)) {
3658
+ if (!ANCHOR_RECEIPT_FIELDS.includes(k)) {
3659
+ return anchorBadReceipt(k, `receipt has unknown field: ${JSON.stringify(k)}`);
3660
+ }
3661
+ }
3662
+ for (const k of ANCHOR_RECEIPT_REQUIRED) {
3663
+ if (!(k in receipt)) {
3664
+ return anchorBadReceipt(k, `receipt is missing required field: ${JSON.stringify(k)}`);
3665
+ }
3666
+ }
3667
+ if (receipt.kind !== ANCHORED_RECEIPT_KIND) {
3668
+ return anchorBadReceipt(
3669
+ "kind",
3670
+ `not an anchored receipt this build understands (kind: ${JSON.stringify(receipt.kind)}; expected ${JSON.stringify(ANCHORED_RECEIPT_KIND)})`
3671
+ );
3672
+ }
3673
+ if (receipt.note !== ANCHOR_TRUST_NOTE) {
3674
+ return anchorBadReceipt("note", "receipt `note` must be the standing trust note VERBATIM (the caveat must not drift)");
3675
+ }
3676
+ if (typeof receipt.digest !== "string" || !ANCHOR_HEX32_LC_RE.test(receipt.digest)) {
3677
+ return anchorBadReceipt("digest", `receipt digest must be a LOWERCASE 0x-bytes32 hex string, got: ${String(receipt.digest)}`);
3678
+ }
3679
+ if (typeof receipt.artifactKind !== "string" || !ANCHOR_ARTIFACT_KINDS.includes(receipt.artifactKind)) {
3680
+ return anchorBadReceipt(
3681
+ "artifactKind",
3682
+ `receipt artifactKind ${JSON.stringify(receipt.artifactKind)} is not in the closed table (${ANCHOR_ARTIFACT_KINDS.join(", ")})`
3683
+ );
3684
+ }
3685
+ if (!anchorHowValidFor(receipt.artifactKind, receipt.how)) {
3686
+ return anchorBadReceipt("how", `receipt \`how\` is not the documented derivation rule for ${receipt.artifactKind}`);
3687
+ }
3688
+ if (receipt.artifactLabel !== undefined) {
3689
+ const l = receipt.artifactLabel;
3690
+ if (typeof l !== "string" || l.length === 0 || l.length > 200 || ANCHOR_CONTROL_CHAR_RE.test(l)) {
3691
+ return anchorBadReceipt(
3692
+ "artifactLabel",
3693
+ "artifactLabel, when present, must be a 1..200-char string with no control characters"
3694
+ );
3695
+ }
3696
+ }
3697
+ const c = anchorCheckChain(receipt.chain);
3698
+ if (!c.ok) return anchorBadReceipt(c.field, c.detail);
3699
+ return { ok: true };
3700
+ }
3701
+
3702
+ /**
3703
+ * Verify that `receipt` is a well-formed `vh-anchored-receipt@1` AND that it binds EXACTLY the
3704
+ * supplied `artifact` — the OFFLINE binding leg, standalone: the digest is RECOMPUTED from the
3705
+ * artifact via the closed table (never trusted from either side) and the full { kind, digest, how }
3706
+ * triple must match. NEVER consults a network; the receipt's chain facts are returned as the
3707
+ * anchorer's CLAIM. TOTAL: named rejects, no throws. Same verdicts as the producer core.
3708
+ *
3709
+ * @param {object} args { receipt, artifact } — both caller-supplied PARSED objects
3710
+ * @returns {{ ok:true, digest:string, chain:object } |
3711
+ * { ok:false, reason:string, field?:string, detail?:string }}
3712
+ */
3713
+ function verifyAnchoredReceipt(args) {
3714
+ try {
3715
+ if (!anchorIsPlainObject(args)) {
3716
+ return anchorNo(ANCHOR_REASONS.BAD_ARGS, "verifyAnchoredReceipt requires { receipt, artifact }");
3717
+ }
3718
+ const r = anchorValidateReceipt(args.receipt);
3719
+ if (!r.ok) return r;
3720
+ const d = anchorArtifactDigest(args.artifact);
3721
+ if (!d.ok) return d; // the artifact's OWN named validation reject, propagated verbatim
3722
+ const receipt = args.receipt;
3723
+ if (d.kind !== receipt.artifactKind) {
3724
+ return anchorNo(
3725
+ ANCHOR_REASONS.KIND_MISMATCH,
3726
+ `receipt anchors a ${receipt.artifactKind} but the supplied artifact is a ${d.kind}`
3727
+ );
3728
+ }
3729
+ if (d.digest !== receipt.digest) {
3730
+ return anchorNo(
3731
+ ANCHOR_REASONS.DIGEST_MISMATCH,
3732
+ `recomputed digest ${d.digest} != receipt digest ${receipt.digest} — this receipt does not bind this artifact`
3733
+ );
3734
+ }
3735
+ if (d.how !== receipt.how) {
3736
+ return anchorNo(ANCHOR_REASONS.HOW_MISMATCH, `recomputed derivation rule != receipt \`how\` (recomputed: ${d.how})`);
3737
+ }
3738
+ return { ok: true, digest: d.digest, chain: anchorCanonicalChain(receipt.chain) };
3739
+ } catch (e) {
3740
+ return anchorNo(ANCHOR_REASONS.BAD_ARGS, e && e.message ? e.message : String(e));
3741
+ }
3742
+ }
3743
+
3744
+ // ---------------------------------------------------------------------------------------------------
3745
+ // The anchored-receipt CLI leg: read + parse the two files, run the pure binding verify, render the
3746
+ // stable human/JSON verdict. READ-ONLY (no receipt/temp/side-effect file is ever written); exit
3747
+ // contract 0 ACCEPTED / 3 REJECTED (named) / 2 usage / 1 IO — the family's shared verify contract.
3748
+ // ---------------------------------------------------------------------------------------------------
3749
+
3750
+ // The in-band honesty of the offline leg, stated once for both output shapes.
3751
+ const ANCHOR_OFFLINE_NOTE =
3752
+ "OFFLINE binding check: the receipt binds this exact artifact, but its chain facts were NOT " +
3753
+ "re-checked (this standalone verifier opens no network). Confirm them against the chain with the " +
3754
+ "producer cli: vh verify-anchored <receipt> <sealed-file> --rpc <url> --contract <addr>.";
3755
+
3756
+ // ---------------------------------------------------------------------------------------------------
3757
+ // CHAIN-CLASS trust guidance for the OFFLINE leg. The offline binding leg proves the receipt binds
3758
+ // THIS artifact; it can NEVER (offline, by definition) confirm the digest is actually anchored on any
3759
+ // chain. But it CAN classify the chain the receipt CLAIMS — and that classification is the single most
3760
+ // load-bearing thing a counterparty needs to avoid this vertical's worst overclaim: mistaking a
3761
+ // receipt from a worthless LOCAL DEV chain (STRATEGY.md P-2 — a local-chain anchor proves MECHANISM
3762
+ // only and is worth NOTHING publicly) for a public-chain proof. Surfacing it HERE puts the check in
3763
+ // the INDEPENDENT verifier a counterparty actually runs, not only in the producer's prose, and makes
3764
+ // it MACHINE-GATEABLE (`chainClass` / `publiclyMeaningful` in --json — a stable, additive contract a
3765
+ // future indexer/UI keys on). The id sets MIRROR the producer's cli/anchor.js KNOWN_TESTNET_CHAIN_IDS
3766
+ // (test/verifier.standalone.test.js pins them against it byte-for-byte so the two sides cannot drift):
3767
+ // the two generic dev chains are LOCAL-DEV, the remaining known ids are PUBLIC TESTNETS, and every
3768
+ // other id is UNKNOWN (a chain — possibly a mainnet — whose weight this offline leg cannot judge).
3769
+ //
3770
+ // This guidance is STRICTLY ADDITIVE: it never changes the accept/reject decision (a bound receipt is
3771
+ // still ACCEPTED at exit 0) and it never touches the pure `verifyAnchoredReceipt` verdict object,
3772
+ // which stays a byte-faithful port of the producer core. It is presentation-layer trust context only.
3773
+ const ANCHOR_LOCAL_DEV_CHAIN_IDS = Object.freeze([31337, 1337]);
3774
+ const ANCHOR_PUBLIC_TESTNET_CHAIN_IDS = Object.freeze([
3775
+ 80002, 80001, 11155111, 17000, 5, 11155420, 84532, 421614,
3776
+ ]);
3777
+
3778
+ // Classify the chainId a receipt CLAIMS into { chainClass, publiclyMeaningful, advisory }. TOTAL — a
3779
+ // non-integer/out-of-set id falls through to the honest "unknown" bucket (never throws). `chainId`
3780
+ // arrives already strict-validated (a positive safe integer) from anchorCheckChain.
3781
+ function anchorClassifyChainId(chainId) {
3782
+ if (ANCHOR_LOCAL_DEV_CHAIN_IDS.includes(chainId)) {
3783
+ return {
3784
+ chainClass: "local-dev",
3785
+ publiclyMeaningful: false,
3786
+ advisory:
3787
+ `this receipt's chain (chainId ${chainId}) is a LOCAL DEV chain: the anchor proves MECHANISM ` +
3788
+ `ONLY and is worth NOTHING publicly until a human deploys the registry to a public chain ` +
3789
+ `(STRATEGY.md P-2). Do NOT treat a local-dev receipt as a public proof.`,
3790
+ };
3791
+ }
3792
+ if (ANCHOR_PUBLIC_TESTNET_CHAIN_IDS.includes(chainId)) {
3793
+ return {
3794
+ chainClass: "public-testnet",
3795
+ publiclyMeaningful: false,
3796
+ advisory:
3797
+ `this receipt's chain (chainId ${chainId}) is a PUBLIC TESTNET: an anchor there demonstrates ` +
3798
+ `the mechanism on a public chain but carries NO economic finality — treat it as a testnet ` +
3799
+ `proof, never a mainnet one.`,
3800
+ };
3801
+ }
3802
+ return {
3803
+ chainClass: "unknown",
3804
+ publiclyMeaningful: null,
3805
+ advisory:
3806
+ `this receipt's chainId ${chainId} is outside this verifier's known local/testnet set (it may ` +
3807
+ `be a mainnet): the OFFLINE leg cannot weigh the chain — re-check the anchor against that chain ` +
3808
+ `before relying on it.`,
3809
+ };
3810
+ }
3811
+
3812
+ function anchorReadJson(label, filePath) {
3813
+ let text;
3814
+ try {
3815
+ text = fs.readFileSync(path.resolve(filePath), "utf8");
3816
+ } catch (e) {
3817
+ throw new IOError(`cannot read ${label} ${filePath}: ${e.message}`);
3818
+ }
3819
+ let obj;
3820
+ try {
3821
+ obj = JSON.parse(text);
3822
+ } catch (e) {
3823
+ throw new IOError(`${label} ${filePath} is not valid JSON: ${e.message}`);
3824
+ }
3825
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
3826
+ throw new IOError(`${label} ${filePath} must be a JSON object`);
3827
+ }
3828
+ return obj;
3829
+ }
3830
+
3831
+ function runVerifyAnchoredOffline(opts, write, writeErr) {
3832
+ let receipt;
3833
+ let artifact;
3834
+ try {
3835
+ receipt = anchorReadJson("receipt", opts.artifact);
3836
+ artifact = anchorReadJson("artifact", opts.anchoredArtifact);
3837
+ } catch (e) {
3838
+ writeErr(`error: ${e.message}\n`);
3839
+ return EXIT.IO;
3840
+ }
3841
+
3842
+ const v = verifyAnchoredReceipt({ receipt, artifact });
3843
+ if (!v.ok) {
3844
+ if (opts.json) {
3845
+ write(
3846
+ JSON.stringify(
3847
+ { ok: false, verdict: "REJECTED", mode: "offline", reason: v.reason, field: v.field, detail: v.detail },
3848
+ null,
3849
+ 2
3850
+ ) + "\n"
3851
+ );
3852
+ } else {
3853
+ writeErr(`verify-vh anchored-receipt: REJECTED (${v.reason})${v.detail ? `: ${v.detail}` : ""}\n`);
3854
+ }
3855
+ return EXIT.REJECTED;
3856
+ }
3857
+
3858
+ // Classify the chain the receipt CLAIMS (additive trust context — never changes the ACCEPT verdict).
3859
+ const cls = anchorClassifyChainId(v.chain.chainId);
3860
+
3861
+ if (opts.json) {
3862
+ write(
3863
+ JSON.stringify(
3864
+ {
3865
+ ok: true,
3866
+ verdict: "ACCEPTED",
3867
+ mode: "offline",
3868
+ digest: v.digest,
3869
+ artifactKind: receipt.artifactKind,
3870
+ chain: v.chain,
3871
+ chainClass: cls.chainClass,
3872
+ publiclyMeaningful: cls.publiclyMeaningful,
3873
+ chainAdvisory: cls.advisory,
3874
+ registry: null,
3875
+ note: ANCHOR_OFFLINE_NOTE,
3876
+ },
3877
+ null,
3878
+ 2
3879
+ ) + "\n"
3880
+ );
3881
+ } else {
3882
+ const c = v.chain;
3883
+ write("verify-vh anchored-receipt: ACCEPTED (offline binding check)\n");
3884
+ write(` digest: ${v.digest}\n`);
3885
+ write(` kind: ${receipt.artifactKind}\n`);
3886
+ write(
3887
+ ` chain CLAIM: chainId ${c.chainId}, contract ${c.contract}, tx ${c.txHash}, ` +
3888
+ `block ${c.blockNumber}, blockTime ${c.blockTime}, contributor ${c.contributor}, ` +
3889
+ `authorBound ${c.authorBound}\n`
3890
+ );
3891
+ write(` chain class: ${cls.chainClass} (publiclyMeaningful: ${cls.publiclyMeaningful})\n`);
3892
+ // For anything not proven publicly meaningful, lead with a WARNING so a counterparty cannot skim
3893
+ // past the caveat; a local-dev receipt (the committed-fixture case) is worth NOTHING publicly.
3894
+ write(` ${cls.publiclyMeaningful === true ? "ADVISORY" : "WARNING"}: ${cls.advisory}\n`);
3895
+ write(
3896
+ " NOTE: the OFFLINE binding leg only — the chain facts above are the anchorer's CLAIM, not " +
3897
+ "re-checked against any chain. Confirm them with the producer cli: " +
3898
+ "vh verify-anchored <receipt> <sealed-file> --rpc <url> --contract <addr>.\n"
3899
+ );
3900
+ }
3901
+ return EXIT.OK;
3902
+ }
3903
+
2972
3904
  // ---------------------------------------------------------------------------
2973
3905
  // Argument parsing.
2974
3906
  // SINGLE-ARTIFACT (the original, byte-for-byte unchanged contract):
@@ -2992,6 +3924,7 @@ function parseArgs(argv) {
2992
3924
  manifest: undefined,
2993
3925
  revocations: undefined,
2994
3926
  asOf: undefined,
3927
+ anchoredArtifact: undefined,
2995
3928
  _pos: [],
2996
3929
  };
2997
3930
  for (let i = 0; i < argv.length; i++) {
@@ -3014,6 +3947,9 @@ function parseArgs(argv) {
3014
3947
  case "--revocations":
3015
3948
  opts.revocations = need("--revocations");
3016
3949
  break;
3950
+ case "--anchored-artifact":
3951
+ opts.anchoredArtifact = need("--anchored-artifact");
3952
+ break;
3017
3953
  case "--as-of":
3018
3954
  opts.asOf = need("--as-of");
3019
3955
  break;
@@ -3060,6 +3996,33 @@ function parseArgs(argv) {
3060
3996
  );
3061
3997
  }
3062
3998
  }
3999
+ // ANCHORED-RECEIPT leg (T-70.4): `--anchored-artifact <sealed-file>` pairs ONE receipt positional
4000
+ // with ONE sealed artifact. It is a dedicated two-file binding check, so the sibling-verify flags
4001
+ // (--vendor/--dir/--revocations/--as-of) and the batch/manifest modes do not compose with it — each
4002
+ // incompatible combination is a NAMED usage error up front, never a silently-ignored flag.
4003
+ if (opts.anchoredArtifact !== undefined) {
4004
+ if (opts.manifest !== undefined) {
4005
+ throw new UsageError("--anchored-artifact verifies ONE receipt; it cannot be combined with --manifest");
4006
+ }
4007
+ for (const [flag, val] of [
4008
+ ["--vendor", opts.vendor],
4009
+ ["--dir", opts.dir],
4010
+ ["--revocations", opts.revocations],
4011
+ ["--as-of", opts.asOf],
4012
+ ]) {
4013
+ if (val !== undefined) {
4014
+ throw new UsageError(
4015
+ `${flag} does not apply to the anchored-receipt binding check (--anchored-artifact reads exactly two files: the receipt and the sealed artifact)`
4016
+ );
4017
+ }
4018
+ }
4019
+ if (opts._pos.length !== 1) {
4020
+ throw new UsageError(
4021
+ "--anchored-artifact requires exactly ONE <receipt> positional: verify-vh <receipt> --anchored-artifact <sealed-file>"
4022
+ );
4023
+ }
4024
+ opts.batch = false;
4025
+ }
3063
4026
  // Preserve the SINGLE-artifact contract verbatim: exactly one positional and no --manifest.
3064
4027
  opts.artifact = opts._pos[0];
3065
4028
  return opts;
@@ -3262,6 +4225,15 @@ function verifyArtifact(opts) {
3262
4225
  throw new IOError(`artifact ${opts.artifact} must be a JSON object`);
3263
4226
  }
3264
4227
 
4228
+ // A bare anchored receipt reached the sibling-verify path: point the caller at the two-file binding
4229
+ // check instead of the generic "unrecognized kind" (a receipt alone carries nothing to re-derive).
4230
+ if (obj.kind === ANCHORED_RECEIPT_KIND) {
4231
+ throw new UsageError(
4232
+ `${opts.artifact} is a ${ANCHORED_RECEIPT_KIND} anchored receipt — verify its OFFLINE binding ` +
4233
+ "leg against the sealed artifact it anchors: verify-vh <receipt> --anchored-artifact <sealed-file>"
4234
+ );
4235
+ }
4236
+
3265
4237
  // The base directory siblings resolve against: --dir override else the artifact's own directory.
3266
4238
  const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(artifactPath);
3267
4239
 
@@ -3821,6 +4793,7 @@ function usage() {
3821
4793
  " verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir> [--as-of <ISO>]] [--json]",
3822
4794
  " verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
3823
4795
  " verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
4796
+ " verify-vh <receipt> --anchored-artifact <sealed-file> [--json] (anchored-receipt binding check)",
3824
4797
  "",
3825
4798
  "DEMO: `verify-vh demo` runs a self-contained, genuinely-signed packet through the real verify path —",
3826
4799
  "NO flags, NO key, NO install state: it ACCEPTs the packet (naming the signer), then REJECTs a one-byte-",
@@ -3843,6 +4816,15 @@ function usage() {
3843
4816
  "third-party revocation is IGNORED with a warning. This reaches the SAME downgrade the producer's",
3844
4817
  "`vh ... verify-signed --revocations` does, OFFLINE — no producer stack, no network, no key.",
3845
4818
  "",
4819
+ "ANCHORED RECEIPTS (T-70.4): a `vh-anchored-receipt@1` produced by `vh anchor-artifact` verifies",
4820
+ "here WITHOUT the producer stack: --anchored-artifact <sealed-file> re-derives the sealed artifact's",
4821
+ "digest through the SAME closed kind table (evidence seal, agent-session packet, journal tree head,",
4822
+ "TrustLedger seal, dataset/parcel attestation), validates the receipt strictly (a drifted trust note",
4823
+ "is a named bad-receipt), and confirms the receipt binds EXACTLY those bytes — ACCEPTED exit 0, or",
4824
+ "the specific named reject (digest-mismatch / kind-mismatch / how-mismatch / bad-receipt / the",
4825
+ "artifact's own named reject) exit 3. OFFLINE binding leg ONLY: the receipt's `chain` facts remain",
4826
+ "the anchorer's CLAIM — re-check them on chain with the producer cli (`vh verify-anchored --rpc`).",
4827
+ "",
3846
4828
  "BATCH/MANIFEST: pass several <artifact> args, or --manifest <file> (a newline list or JSON array of",
3847
4829
  "artifact paths, each line/object may carry its own --vendor/--dir). ALL must pass for exit 0; if ANY",
3848
4830
  "is rejected, exit is 3 and the report names which artifact failed and why. --json emits a stable",
@@ -3909,6 +4891,12 @@ function run(argv, io = {}) {
3909
4891
  return EXIT.USAGE;
3910
4892
  }
3911
4893
 
4894
+ // ANCHORED-RECEIPT binding check (T-70.4): a dedicated two-file leg — parseArgs already guaranteed
4895
+ // exactly one <receipt> positional and no incompatible flag. READ-ONLY; exit 0/3/2/1 as everywhere.
4896
+ if (opts.anchoredArtifact !== undefined) {
4897
+ return runVerifyAnchoredOffline(opts, write, writeErr);
4898
+ }
4899
+
3912
4900
  // The recipient's current decision instant (the default --as-of). Injectable via io.nowISO so a test can
3913
4901
  // pin the clock; otherwise the wall clock. Threaded onto opts for the (optional) revocation evaluation.
3914
4902
  opts.nowISO = io.nowISO || new Date().toISOString();
@@ -3989,6 +4977,20 @@ module.exports = {
3989
4977
  verifyProofBundle,
3990
4978
  verifyAgentSeal,
3991
4979
  AGENT_TRUST_NOTE,
4980
+ // ANCHORED-RECEIPT surface (T-70.4) — wire-format constants + the pure binding verify, exported so
4981
+ // the parity test can pin them against the producer core (cli/core/anchor-binding.js) byte-for-byte.
4982
+ ANCHORED_RECEIPT_KIND,
4983
+ ANCHOR_TRUST_NOTE,
4984
+ ANCHOR_REASONS,
4985
+ ANCHOR_ARTIFACT_KINDS,
4986
+ ANCHOR_JOURNAL_TREE_HEAD_KIND,
4987
+ ANCHOR_JOURNAL_EMPTY_ROOT,
4988
+ ANCHOR_LOCAL_DEV_CHAIN_IDS,
4989
+ ANCHOR_PUBLIC_TESTNET_CHAIN_IDS,
4990
+ anchorClassifyChainId,
4991
+ anchorArtifactDigest,
4992
+ verifyAnchoredReceipt,
4993
+ runVerifyAnchoredOffline,
3992
4994
  renderHuman,
3993
4995
  revocation,
3994
4996
  usage,
@@ -4015,7 +5017,7 @@ var __PROVENANCE = {
4015
5017
  "schema": "verifyhash/build-provenance@1",
4016
5018
  "target": "verify",
4017
5019
  "note": "This bundle's OWN provenance, embedded so the single file is self-describing. Run `node verify-vh-standalone.js --self-attest` to recompute selfSha256 from these very bytes, or `--provenance` to print the ordered source modules + hashes it was built from. Cross-check against verifier/dist/BUILD-PROVENANCE.json (the same data) with: node verifier/build-standalone.js --check",
4018
- "selfSha256": "97d8a906c8f9d584ff2c5ff88691e4bad334180f8894afa1d3c7a6b4aff2d8e8",
5020
+ "selfSha256": "a5b6a93c77bdf14959bf6a786a242989646bda20afbe7bdc4a173b73e8ffa38c",
4019
5021
  "modules": [
4020
5022
  {
4021
5023
  "id": "keccak256-vendored",
@@ -4077,8 +5079,8 @@ var __PROVENANCE = {
4077
5079
  "id": "verify-vh",
4078
5080
  "synthetic": false,
4079
5081
  "sourceFile": "verifier/verify-vh.js",
4080
- "sourceSha256": "0e948e5973cd0faf3acda4fb14640feed13fde6190e345b2aea083addeb4b662",
4081
- "inlinedSha256": "ad89a1d71240c725255633f27f7f3298c8c9718b33eea7c640cae8d62eea8054",
5082
+ "sourceSha256": "35e04d3adc1f66125f282c76553a33d5551f6c9783948d410c6a52ffa401a807",
5083
+ "inlinedSha256": "fd2eb607800118cafc0d5a79c077150cbe9eb03f5de4a8898cbd30031d412ccc",
4082
5084
  "entry": true
4083
5085
  }
4084
5086
  ]