From ce7a77128258f28b15f335a3c4e2720050553f2e Mon Sep 17 00:00:00 2001 From: Aziel Eliab Date: Sun, 11 Oct 2026 12:34:37 -0400 Subject: [PATCH 1/3] Factor detail: JSON-normalize before hash/store; seal-check rescore lines via RESCORE factor_detail_sha256 --- workers/godlock-uk/factor-detail.test.mjs | 53 ++++++++++++++++++++++- workers/godlock-uk/src/factorDetail.js | 24 ++++++++-- workers/godlock-uk/src/index.js | 3 +- workers/godlock-uk/src/ui.js | 7 +-- 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/workers/godlock-uk/factor-detail.test.mjs b/workers/godlock-uk/factor-detail.test.mjs index 6a6ac42..3501744 100644 --- a/workers/godlock-uk/factor-detail.test.mjs +++ b/workers/godlock-uk/factor-detail.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { scoreDebateText } from "./src/debateScore.js"; -import { factorDetail, storedFactorView, factorDetailSealed, NOT_STORED, NOT_SEALED_LABEL, FACTOR_KEYS } from "./src/factorDetail.js"; +import { factorDetail, storedFactorView, factorDetailSealed, sealRescoreChanges, NOT_STORED, NOT_SEALED_LABEL, FACTOR_KEYS } from "./src/factorDetail.js"; import { hashPayload, canonicalJson, ZERO } from "./src/ledger.js"; import { receiptLedgerAndProof, _resetProofCache, HEAD_SQL, TAIL_SQL, BY_RECEIPT_SQL } from "./src/receiptProof.js"; import { threadView, threadExpandScript } from "./src/ui.js"; @@ -77,7 +77,7 @@ describe("drop-down view", () => { }); it("rescore changes render with signed deltas", () => { const v = threadView({ ...base, receipt: { factor_detail: null, rescore_factor_changes: [{ created_utc: "t2", changes: [{ factor: "spre", before: 0.15, after: 0.3, delta: 0.15 }] }] } }); - assert.deepEqual(v.factors[2], ["Rescore t2", "spre 0.15 → 0.3 (+0.15)"]); + assert.deepEqual(v.factors[2], ["Rescore t2", "spre 0.15 → 0.3 (+0.15) (stored data, not covered)"]); }); it("script uses textContent only and still parses", () => { const js = threadExpandScript(); @@ -145,3 +145,52 @@ describe("factor detail seal coverage", () => { assert.deepEqual(v.factors.find((x) => x[0] === "Pairing a × b"), ["Pairing a × b", "0.1235"]); }); }); + +describe("normalized detail and rescore sealing", () => { + it("factorDetail is JSON-normalized: no undefined fields, hash survives a store round trip", () => { + const review = scoreDebateText(TEXT, ""); + review.spre.band = undefined; // checker leaves a nested field undefined + const fd = factorDetail(review); + const roundTrip = JSON.parse(JSON.stringify({ factor_detail: fd })).factor_detail; + assert.deepEqual(roundTrip, fd); + assert.equal(hashPayload(roundTrip), hashPayload(fd)); + assert.ok(!("band" in fd.factors.spre.checker)); + }); + const fd1 = factorDetail(scoreDebateText(TEXT, "")); + const fd2 = factorDetail(scoreDebateText(TEXT + " Measured evidence from an archive.", "")); + const history = (d2) => [ + { id: "i0", reason: "initial", triad_json: JSON.stringify({ factors: fd1.factors && { spre: 0.15 }, factor_detail: fd1 }) }, + { id: "rs1", reason: "supersedes", created_utc: "t2", triad_json: JSON.stringify({ factors: { spre: 0.3 }, factor_detail: d2 }) }, + ]; + const ledger = () => ledgerWith([ + ["SUBMIT", { receipt_id: "r1", factor_detail_sha256: hashPayload(fd1) }], + ["RESCORE", { receipt_id: "r1", rescore_id: "rs1", factor_detail_sha256: hashPayload(fd2) }], + ]); + it("rescore line covered when its RESCORE entry sha matches", async () => { + _resetProofCache(); + const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger()) }, "r1"); + const fv = storedFactorView(history(fd2)); + sealRescoreChanges(fv.rescore_factor_changes, lr, proof); + assert.equal(fv.rescore_factor_changes[0].sealed, true); + const v = threadView({ ok: true, proof, receipt: { ...fv, factor_detail_sealed: true } }); + assert.match(v.factors.find((x) => x[0] === "Rescore t2")[1], /\(covered by seal\)$/); + }); + it("rescore detail edited after write: stored data, not covered", async () => { + _resetProofCache(); + const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger()) }, "r1"); + const bad = JSON.parse(JSON.stringify(fd2)); bad.combined = 0.99; + const fv = storedFactorView(history(bad)); + sealRescoreChanges(fv.rescore_factor_changes, lr, proof); + assert.equal(fv.rescore_factor_changes[0].sealed, false); + const v = threadView({ ok: true, proof, receipt: fv }); + assert.match(v.factors.find((x) => x[0] === "Rescore t2")[1], /\(stored data, not covered\)$/); + }); + it("SUBMIT sha never seals a rescore line", async () => { + _resetProofCache(); + const only = ledgerWith([["SUBMIT", { receipt_id: "r1", factor_detail_sha256: hashPayload(fd2) }]]); + const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(only) }, "r1"); + const fv = storedFactorView(history(fd2)); + sealRescoreChanges(fv.rescore_factor_changes, lr, proof); + assert.equal(fv.rescore_factor_changes[0].sealed, false); + }); +}); diff --git a/workers/godlock-uk/src/factorDetail.js b/workers/godlock-uk/src/factorDetail.js index 9c0165d..52dbb27 100644 --- a/workers/godlock-uk/src/factorDetail.js +++ b/workers/godlock-uk/src/factorDetail.js @@ -44,8 +44,13 @@ function applicabilityReason(review, key) { return src && typeof src.reason === "string" && src.reason ? src.reason : NO_REASON; } -/** Build at write time. Returns null when the checker produced no triad. */ +/** Build at write time, normalized through JSON so the stored bytes and the hashed value agree. */ export function factorDetail(review) { + const raw = rawFactorDetail(review); + return raw == null ? null : JSON.parse(JSON.stringify(raw)); +} + +function rawFactorDetail(review) { const triad = review && review.triad; if (!triad || !triad.factors) return null; const flags = (review.applicability && review.applicability.flags) || {}; @@ -101,7 +106,8 @@ export function storedFactorView(rows) { for (const r of list) { if (!r || r === initial) continue; const snap = parse(r.triad_json); - const entry = { created_utc: r.created_utc, reason: r.reason, changes: null, note: null }; + const entry = { id: r.id == null ? null : r.id, created_utc: r.created_utc, reason: r.reason, changes: null, note: null, sealed: false }; + Object.defineProperty(entry, "_detail", { value: snap && snap.factor_detail && snap.factor_detail.schema === FACTOR_DETAIL_SCHEMA ? snap.factor_detail : null, enumerable: false }); if (!prev || !prev.factors || !snap || !snap.factors) { entry.note = NOT_STORED; } else { @@ -127,14 +133,15 @@ export const SEALED_LABEL = "Per-factor detail is covered by the seal: its sha25 * matches the verified checkpoint, it carries factor_detail_sha256, and the * sha256 of the stored factor_detail (canonical JSON) equals it. Else false. */ -export function factorDetailSealed(detail, ledgerRows, proof) { +export function factorDetailSealed(detail, ledgerRows, proof, match) { if (!detail || !proof || proof.status !== "verified") return false; + const isMine = typeof match === "function" ? match : (row) => String(row.action) === "SUBMIT"; const entries = Array.isArray(proof.entries) ? proof.entries : []; const rows = Array.isArray(ledgerRows) ? ledgerRows : []; for (const row of rows) { - if (String(row.action) !== "SUBMIT") continue; let payload; try { payload = JSON.parse(row.payload_json || "{}"); } catch { continue; } + if (!isMine(row, payload)) continue; const want = payload && payload.factor_detail_sha256; if (typeof want !== "string" || want.length !== 64) continue; const e = entries.find((x) => Number(x.sequence) === Number(row.sequence) && x.entry_hash === row.entry_hash); @@ -143,3 +150,12 @@ export function factorDetailSealed(detail, ledgerRows, proof) { } return false; } + +/** Mark each rescore change line sealed only if its RESCORE entry's factor_detail_sha256 checks out. */ +export function sealRescoreChanges(changes, ledgerRows, proof) { + for (const c of Array.isArray(changes) ? changes : []) { + c.sealed = !!c.id && factorDetailSealed(c._detail, ledgerRows, proof, + (row, payload) => String(row.action) === "RESCORE" && payload && payload.rescore_id === c.id); + } + return changes; +} diff --git a/workers/godlock-uk/src/index.js b/workers/godlock-uk/src/index.js index ec216df..bcbbb61 100644 --- a/workers/godlock-uk/src/index.js +++ b/workers/godlock-uk/src/index.js @@ -24,7 +24,7 @@ import { donateDoc, DONATE_RAILS, donateQrIdFromPath } from "./donate.js"; import { handleRuntimeRoot, isRuntimeRequest, runtimeCors } from "./runtimeRoot.js"; import { appendLedger, verifyLedger, ledgerEntriesForId, sha256hex, publicEntry, hashPayload } from "./ledger.js"; import { receiptLedgerAndProof } from "./receiptProof.js"; -import { factorDetail, storedFactorView, factorDetailSealed } from "./factorDetail.js"; +import { factorDetail, storedFactorView, factorDetailSealed, sealRescoreChanges } from "./factorDetail.js"; import { robotsTxt, sitemapXml, citeDoc, llmsDoc, aiDoc, siteOpenApi, mcpDiscoveryDoc, BANNER, DOWNLOAD, DOWNLOAD_STATS, DOWNLOAD_COUNT, GITHUB, AUTHOR, indexNowKeyBody, INDEXNOW_KEY_PATH, INDEXNOW_WELL_KNOWN_PATH, @@ -1288,6 +1288,7 @@ export default { ok: true, receipt: (() => { const fv = storedFactorView(row.rescores); + sealRescoreChanges(fv.rescore_factor_changes, ledgerRows, proof); return { ...publicPayload(row), ...fv, factor_detail_sealed: factorDetailSealed(fv.factor_detail, ledgerRows, proof) }; })(), ledger: entries, diff --git a/workers/godlock-uk/src/ui.js b/workers/godlock-uk/src/ui.js index d4a0ebf..c4e50b9 100644 --- a/workers/godlock-uk/src/ui.js +++ b/workers/godlock-uk/src/ui.js @@ -559,14 +559,15 @@ export function threadView(j) { for (var c = 0; c < rc.length; c++) { var e = rc[c] || {}; var label = "Rescore " + fmt(e.created_utc); - if (!Array.isArray(e.changes)) { out.factors.push([label, "per-factor detail not stored for this receipt"]); continue; } - if (!e.changes.length) { out.factors.push([label, "no factor value changed"]); continue; } + var sfx = e.sealed === true ? " (covered by seal)" : " (stored data, not covered)"; + if (!Array.isArray(e.changes)) { out.factors.push([label, "per-factor detail not stored for this receipt" + sfx]); continue; } + if (!e.changes.length) { out.factors.push([label, "no factor value changed" + sfx]); continue; } var bits = []; for (var d = 0; d < e.changes.length; d++) { var ch = e.changes[d]; bits.push(ch.factor + " " + (ch.before == null ? "omitted" : ch.before) + " → " + (ch.after == null ? "omitted" : ch.after) + (ch.delta == null ? "" : " (" + (ch.delta > 0 ? "+" : "") + ch.delta + ")")); } - out.factors.push([label, bits.join("; ")]); + out.factors.push([label, bits.join("; ") + sfx]); } var p = j.proof; if (!p || typeof p !== "object") { From 192a4b79e1f519ecafe7a23f897ea19b6998c7f1 Mon Sep 17 00:00:00 2001 From: Aziel Eliab Date: Sun, 11 Oct 2026 12:36:17 -0400 Subject: [PATCH 2/3] Rescore lines: values only from hashed factor_detail; covered only if own and previous detail are sealed --- workers/godlock-uk/factor-detail.test.mjs | 54 ++++++++++++++++++++++- workers/godlock-uk/src/factorDetail.js | 42 +++++++++++++----- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/workers/godlock-uk/factor-detail.test.mjs b/workers/godlock-uk/factor-detail.test.mjs index 3501744..bd20e47 100644 --- a/workers/godlock-uk/factor-detail.test.mjs +++ b/workers/godlock-uk/factor-detail.test.mjs @@ -38,6 +38,12 @@ describe("factor detail at write time", () => { it("no triad means no detail", () => assert.equal(factorDetail({}), null)); }); +function mkDetail(vals) { + const factors = {}; + for (const k of Object.keys(vals)) factors[k] = { value: vals[k] }; + return { schema: "godlock.factor_detail.v1", cycle_factors: Object.keys(vals), factors, pairings: [], omitted: [] }; +} + describe("stored factor view (read time, no recompute)", () => { it("old receipt without stored detail says so", () => { const v = storedFactorView([{ reason: "initial", triad_json: JSON.stringify({ factors: { spre: 0.1 } }) }]); @@ -45,8 +51,8 @@ describe("stored factor view (read time, no recompute)", () => { }); it("rescore rows show which factors changed and by how much", () => { const v = storedFactorView([ - { reason: "initial", triad_json: JSON.stringify({ factors: { spre: 0.15, bayesian: 0.5, clce: null } }) }, - { reason: "supersedes", created_utc: "t2", triad_json: JSON.stringify({ factors: { spre: 0.3, bayesian: 0.5, clce: 0.2 } }) }, + { reason: "initial", triad_json: JSON.stringify({ factor_detail: mkDetail({ spre: 0.15, bayesian: 0.5 }) }) }, + { reason: "supersedes", created_utc: "t2", triad_json: JSON.stringify({ factor_detail: mkDetail({ spre: 0.3, bayesian: 0.5, clce: 0.2 }) }) }, ]); assert.deepEqual(v.rescore_factor_changes[0].changes, [ { factor: "clce", before: null, after: 0.2, delta: null }, @@ -194,3 +200,47 @@ describe("normalized detail and rescore sealing", () => { assert.equal(fv.rescore_factor_changes[0].sealed, false); }); }); + +describe("rescore lines render only hashed detail", () => { + const d0 = mkDetail({ spre: 0.15 }); + const d1 = mkDetail({ spre: 0.3 }); + const ledger = (submitSha) => ledgerWith([ + ["SUBMIT", { receipt_id: "r1", factor_detail_sha256: submitSha }], + ["RESCORE", { receipt_id: "r1", rescore_id: "rs1", factor_detail_sha256: hashPayload(d1) }], + ]); + const hist = (extra) => [ + { id: "i0", reason: "initial", triad_json: JSON.stringify({ factors: { spre: 0.15 }, factor_detail: d0 }) }, + { id: "rs1", reason: "x", created_utc: "t2", triad_json: JSON.stringify({ factors: { spre: 0.3 }, factor_detail: d1, ...extra }) }, + ]; + it("both sealed: covered, values from factor_detail", async () => { + _resetProofCache(); + const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger(hashPayload(d0))) }, "r1"); + const fv = storedFactorView(hist({})); + sealRescoreChanges(fv.rescore_factor_changes, lr, proof); + assert.equal(fv.rescore_factor_changes[0].sealed, true); + assert.deepEqual(fv.rescore_factor_changes[0].changes, [{ factor: "spre", before: 0.15, after: 0.3, delta: 0.15 }]); + }); + it("editing triad_json.factors (not factor_detail) changes nothing shown and cannot fake numbers", async () => { + _resetProofCache(); + const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger(hashPayload(d0))) }, "r1"); + const fv = storedFactorView(hist({ factors: { spre: 0.99 } })); + sealRescoreChanges(fv.rescore_factor_changes, lr, proof); + assert.equal(fv.rescore_factor_changes[0].changes[0].after, 0.3, "unhashed top-level copy is ignored"); + }); + it("edited factor_detail values: not covered", async () => { + _resetProofCache(); + const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger(hashPayload(d0))) }, "r1"); + const fv = storedFactorView(hist({ factor_detail: mkDetail({ spre: 0.99 }) })); + sealRescoreChanges(fv.rescore_factor_changes, lr, proof); + assert.equal(fv.rescore_factor_changes[0].sealed, false); + const v = threadView({ ok: true, proof, receipt: fv }); + assert.match(v.factors.find((x) => x[0] === "Rescore t2")[1], /\(stored data, not covered\)$/); + }); + it("previous snapshot unsealed: not covered even if own detail is sealed", async () => { + _resetProofCache(); + const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger("0".repeat(64))) }, "r1"); + const fv = storedFactorView(hist({})); + sealRescoreChanges(fv.rescore_factor_changes, lr, proof); + assert.equal(fv.rescore_factor_changes[0].sealed, false); + }); +}); diff --git a/workers/godlock-uk/src/factorDetail.js b/workers/godlock-uk/src/factorDetail.js index 52dbb27..9a0d21e 100644 --- a/workers/godlock-uk/src/factorDetail.js +++ b/workers/godlock-uk/src/factorDetail.js @@ -102,25 +102,35 @@ export function storedFactorView(rows) { ? snapInitial.factor_detail : null; const changes = []; - let prev = snapInitial; + const detailOf = (snap) => (snap && snap.factor_detail && snap.factor_detail.schema === FACTOR_DETAIL_SCHEMA ? snap.factor_detail : null); + // Values come only from the hashed factor_detail, never the unhashed top-level triad_json.factors. + const valueOf = (d, key) => { + const f = d && d.factors ? d.factors[key] : null; + return f && f.value != null ? Number(f.value) : null; + }; + let prevDetail = detail; + let prevRef = { kind: "SUBMIT", id: null }; for (const r of list) { if (!r || r === initial) continue; - const snap = parse(r.triad_json); + const d = detailOf(parse(r.triad_json)); const entry = { id: r.id == null ? null : r.id, created_utc: r.created_utc, reason: r.reason, changes: null, note: null, sealed: false }; - Object.defineProperty(entry, "_detail", { value: snap && snap.factor_detail && snap.factor_detail.schema === FACTOR_DETAIL_SCHEMA ? snap.factor_detail : null, enumerable: false }); - if (!prev || !prev.factors || !snap || !snap.factors) { + Object.defineProperty(entry, "_detail", { value: d, enumerable: false }); + Object.defineProperty(entry, "_prevDetail", { value: prevDetail, enumerable: false }); + Object.defineProperty(entry, "_prevRef", { value: prevRef, enumerable: false }); + if (!prevDetail || !d) { entry.note = NOT_STORED; } else { entry.changes = []; for (const key of FACTOR_KEYS) { - const a = prev.factors[key] == null ? null : Number(prev.factors[key]); - const b = snap.factors[key] == null ? null : Number(snap.factors[key]); - if (a === b) continue; - entry.changes.push({ factor: key, before: a, after: b, delta: a != null && b != null ? Math.round((b - a) * 10000) / 10000 : null }); + const x = valueOf(prevDetail, key); + const y = valueOf(d, key); + if (x === y) continue; + entry.changes.push({ factor: key, before: x, after: y, delta: x != null && y != null ? Math.round((y - x) * 10000) / 10000 : null }); } } changes.push(entry); - if (snap) prev = snap; + prevDetail = d; + prevRef = { kind: "RESCORE", id: entry.id }; } return { factor_detail: detail, factor_detail_note: detail ? null : NOT_STORED, rescore_factor_changes: changes }; } @@ -151,11 +161,19 @@ export function factorDetailSealed(detail, ledgerRows, proof, match) { return false; } -/** Mark each rescore change line sealed only if its RESCORE entry's factor_detail_sha256 checks out. */ +/** + * A rescore line is covered only if its own detail is sealed by its RESCORE + * entry AND the previous detail (SUBMIT or prior RESCORE) is sealed too. + */ export function sealRescoreChanges(changes, ledgerRows, proof) { + const rescoreMatch = (id) => (row, payload) => String(row.action) === "RESCORE" && payload && payload.rescore_id === id; for (const c of Array.isArray(changes) ? changes : []) { - c.sealed = !!c.id && factorDetailSealed(c._detail, ledgerRows, proof, - (row, payload) => String(row.action) === "RESCORE" && payload && payload.rescore_id === c.id); + const own = !!c.id && factorDetailSealed(c._detail, ledgerRows, proof, rescoreMatch(c.id)); + const ref = c._prevRef || {}; + const prev = ref.kind === "SUBMIT" + ? factorDetailSealed(c._prevDetail, ledgerRows, proof) + : (!!ref.id && factorDetailSealed(c._prevDetail, ledgerRows, proof, rescoreMatch(ref.id))); + c.sealed = own && prev; } return changes; } From fd00762c64b49023499c743982703b0619ef6bea Mon Sep 17 00:00:00 2001 From: Aziel Eliab Date: Sun, 11 Oct 2026 12:40:26 -0400 Subject: [PATCH 3/3] Rescore lines ordered/paired by hashed ledger seq with ledger timestamps; reasoning seal via SUBMIT content_sha256 --- workers/godlock-uk/factor-detail.test.mjs | 165 ++++++++++------------ workers/godlock-uk/src/factorDetail.js | 149 +++++++++++++------ workers/godlock-uk/src/index.js | 6 +- workers/godlock-uk/src/ui.js | 6 +- workers/godlock-uk/thread-expand.test.mjs | 3 +- 5 files changed, 189 insertions(+), 140 deletions(-) diff --git a/workers/godlock-uk/factor-detail.test.mjs b/workers/godlock-uk/factor-detail.test.mjs index bd20e47..699b977 100644 --- a/workers/godlock-uk/factor-detail.test.mjs +++ b/workers/godlock-uk/factor-detail.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { scoreDebateText } from "./src/debateScore.js"; -import { factorDetail, storedFactorView, factorDetailSealed, sealRescoreChanges, NOT_STORED, NOT_SEALED_LABEL, FACTOR_KEYS } from "./src/factorDetail.js"; +import { factorDetail, storedFactorView, factorDetailSealed, ledgerRescoreLines, reasoningSealed, NOT_STORED, NOT_SEALED_LABEL, FACTOR_KEYS } from "./src/factorDetail.js"; import { hashPayload, canonicalJson, ZERO } from "./src/ledger.js"; import { receiptLedgerAndProof, _resetProofCache, HEAD_SQL, TAIL_SQL, BY_RECEIPT_SQL } from "./src/receiptProof.js"; import { threadView, threadExpandScript } from "./src/ui.js"; @@ -49,21 +49,6 @@ describe("stored factor view (read time, no recompute)", () => { const v = storedFactorView([{ reason: "initial", triad_json: JSON.stringify({ factors: { spre: 0.1 } }) }]); assert.equal(v.factor_detail, null); assert.equal(v.factor_detail_note, NOT_STORED); }); - it("rescore rows show which factors changed and by how much", () => { - const v = storedFactorView([ - { reason: "initial", triad_json: JSON.stringify({ factor_detail: mkDetail({ spre: 0.15, bayesian: 0.5 }) }) }, - { reason: "supersedes", created_utc: "t2", triad_json: JSON.stringify({ factor_detail: mkDetail({ spre: 0.3, bayesian: 0.5, clce: 0.2 }) }) }, - ]); - assert.deepEqual(v.rescore_factor_changes[0].changes, [ - { factor: "clce", before: null, after: 0.2, delta: null }, - { factor: "spre", before: 0.15, after: 0.3, delta: 0.15 }, - ]); - }); - it("rescore without stored factors is not guessed", () => { - const v = storedFactorView([{ reason: "initial", triad_json: null }, { reason: "x", triad_json: JSON.stringify({ factors: { spre: 1 } }) }]); - assert.equal(v.rescore_factor_changes[0].changes, null); - assert.equal(v.rescore_factor_changes[0].note, NOT_STORED); - }); }); describe("drop-down view", () => { @@ -82,8 +67,8 @@ describe("drop-down view", () => { assert.deepEqual(v.factors, [["Seal coverage", NOT_SEALED_LABEL], ["Per-factor", "per-factor detail not stored for this receipt"]]); }); it("rescore changes render with signed deltas", () => { - const v = threadView({ ...base, receipt: { factor_detail: null, rescore_factor_changes: [{ created_utc: "t2", changes: [{ factor: "spre", before: 0.15, after: 0.3, delta: 0.15 }] }] } }); - assert.deepEqual(v.factors[2], ["Rescore t2", "spre 0.15 → 0.3 (+0.15) (stored data, not covered)"]); + const v = threadView({ ...base, receipt: { factor_detail: null, rescore_factor_changes: [{ ledger_seq: 7, ledger_timestamp_utc: "T7", created_utc: "IGNORED", changes: [{ factor: "spre", before: 0.15, after: 0.3, delta: 0.15 }] }] } }); + assert.deepEqual(v.factors.find((x) => x[0].startsWith("Rescore")), ["Rescore T7 (ledger #7)", "spre 0.15 → 0.3 (+0.15) (stored data, not covered)"]); }); it("script uses textContent only and still parses", () => { const js = threadExpandScript(); @@ -162,85 +147,83 @@ describe("normalized detail and rescore sealing", () => { assert.equal(hashPayload(roundTrip), hashPayload(fd)); assert.ok(!("band" in fd.factors.spre.checker)); }); - const fd1 = factorDetail(scoreDebateText(TEXT, "")); - const fd2 = factorDetail(scoreDebateText(TEXT + " Measured evidence from an archive.", "")); - const history = (d2) => [ - { id: "i0", reason: "initial", triad_json: JSON.stringify({ factors: fd1.factors && { spre: 0.15 }, factor_detail: fd1 }) }, - { id: "rs1", reason: "supersedes", created_utc: "t2", triad_json: JSON.stringify({ factors: { spre: 0.3 }, factor_detail: d2 }) }, - ]; - const ledger = () => ledgerWith([ - ["SUBMIT", { receipt_id: "r1", factor_detail_sha256: hashPayload(fd1) }], - ["RESCORE", { receipt_id: "r1", rescore_id: "rs1", factor_detail_sha256: hashPayload(fd2) }], - ]); - it("rescore line covered when its RESCORE entry sha matches", async () => { - _resetProofCache(); - const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger()) }, "r1"); - const fv = storedFactorView(history(fd2)); - sealRescoreChanges(fv.rescore_factor_changes, lr, proof); - assert.equal(fv.rescore_factor_changes[0].sealed, true); - const v = threadView({ ok: true, proof, receipt: { ...fv, factor_detail_sealed: true } }); - assert.match(v.factors.find((x) => x[0] === "Rescore t2")[1], /\(covered by seal\)$/); - }); - it("rescore detail edited after write: stored data, not covered", async () => { - _resetProofCache(); - const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger()) }, "r1"); - const bad = JSON.parse(JSON.stringify(fd2)); bad.combined = 0.99; - const fv = storedFactorView(history(bad)); - sealRescoreChanges(fv.rescore_factor_changes, lr, proof); - assert.equal(fv.rescore_factor_changes[0].sealed, false); - const v = threadView({ ok: true, proof, receipt: fv }); - assert.match(v.factors.find((x) => x[0] === "Rescore t2")[1], /\(stored data, not covered\)$/); - }); - it("SUBMIT sha never seals a rescore line", async () => { - _resetProofCache(); - const only = ledgerWith([["SUBMIT", { receipt_id: "r1", factor_detail_sha256: hashPayload(fd2) }]]); - const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(only) }, "r1"); - const fv = storedFactorView(history(fd2)); - sealRescoreChanges(fv.rescore_factor_changes, lr, proof); - assert.equal(fv.rescore_factor_changes[0].sealed, false); - }); }); -describe("rescore lines render only hashed detail", () => { - const d0 = mkDetail({ spre: 0.15 }); - const d1 = mkDetail({ spre: 0.3 }); - const ledger = (submitSha) => ledgerWith([ - ["SUBMIT", { receipt_id: "r1", factor_detail_sha256: submitSha }], - ["RESCORE", { receipt_id: "r1", rescore_id: "rs1", factor_detail_sha256: hashPayload(d1) }], +describe("rescore lines are ordered and paired by the hashed ledger", () => { + const d0 = mkDetail({ spre: 0.1 }); + const d1 = mkDetail({ spre: 0.2 }); + const d2 = mkDetail({ spre: 0.5 }); + const L = (extra = []) => ledgerWith([ + ["SUBMIT", { receipt_id: "r1", factor_detail_sha256: hashPayload(d0) }], + ["RESCORE", { receipt_id: "r1", rescore_id: "a", reason: "supersedes", factor_detail_sha256: hashPayload(d1) }], + ...extra, + ["RESCORE", { receipt_id: "r1", rescore_id: "b", reason: "same-succession", factor_detail_sha256: hashPayload(d2) }], ]); - const hist = (extra) => [ - { id: "i0", reason: "initial", triad_json: JSON.stringify({ factors: { spre: 0.15 }, factor_detail: d0 }) }, - { id: "rs1", reason: "x", created_utc: "t2", triad_json: JSON.stringify({ factors: { spre: 0.3 }, factor_detail: d1, ...extra }) }, - ]; - it("both sealed: covered, values from factor_detail", async () => { + const H = (tA = "t1", tB = "t2", omit) => [ + { id: "i", reason: "initial", created_utc: "t0", triad_json: JSON.stringify({ factor_detail: d0 }) }, + { id: "a", reason: "x", created_utc: tA, triad_json: JSON.stringify({ factor_detail: d1 }) }, + { id: "b", reason: "x", created_utc: tB, triad_json: JSON.stringify({ factor_detail: d2 }) }, + ].filter((h) => h.id !== omit); + async function run(ledger, hist) { _resetProofCache(); - const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger(hashPayload(d0))) }, "r1"); - const fv = storedFactorView(hist({})); - sealRescoreChanges(fv.rescore_factor_changes, lr, proof); - assert.equal(fv.rescore_factor_changes[0].sealed, true); - assert.deepEqual(fv.rescore_factor_changes[0].changes, [{ factor: "spre", before: 0.15, after: 0.3, delta: 0.15 }]); - }); - it("editing triad_json.factors (not factor_detail) changes nothing shown and cannot fake numbers", async () => { + const { rows, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger) }, "r1"); + return ledgerRescoreLines(hist, rows, proof, "r1"); + } + it("baseline: both covered, ledger order, ledger timestamps and reasons", async () => { + const lines = await run(L(), H()); + assert.deepEqual(lines.map((l) => [l.id, l.ledger_seq, l.sealed, l.reason]), [["a", 2, true, "supersedes"], ["b", 3, true, "same-succession"]]); + assert.equal(lines[0].ledger_timestamp_utc, "t1"); + assert.deepEqual(lines[1].changes, [{ factor: "spre", before: 0.2, after: 0.5, delta: 0.3 }]); + }); + it("swapped created_utc: original ledger order and correct deltas", async () => { + const lines = await run(L(), H("t9", "t0")); + assert.deepEqual(lines.map((l) => l.id), ["a", "b"]); + assert.deepEqual(lines[0].changes, [{ factor: "spre", before: 0.1, after: 0.2, delta: 0.1 }]); + assert.deepEqual(lines[1].changes, [{ factor: "spre", before: 0.2, after: 0.5, delta: 0.3 }]); + assert.ok(lines.every((l) => l.sealed)); + }); + it("history order is irrelevant", async () => { + const lines = await run(L(), H().reverse()); + assert.deepEqual(lines.map((l) => l.id), ["a", "b"]); + }); + it("missing stored row for the middle rescore: it and the next line are not covered", async () => { + const lines = await run(L(), H("t1", "t2", "a")); + assert.equal(lines.find((l) => l.id === "a").sealed, false); + assert.equal(lines.find((l) => l.id === "b").sealed, false); + assert.equal(lines.find((l) => l.id === "b").changes, null); + }); + it("stored rescore with no ledger row: listed, not covered", async () => { + const hist = H().concat([{ id: "ghost", reason: "x", created_utc: "t5", triad_json: JSON.stringify({ factor_detail: d2 }) }]); + const g = (await run(L(), hist)).find((l) => l.id === "ghost"); + assert.equal(g.sealed, false); assert.equal(g.ledger_seq, null); + }); + it("intervening RESCORE for this receipt: b pairs with the intervening row, not with a", async () => { + const dx = mkDetail({ spre: 0.9 }); + const lines = await run(L([["RESCORE", { receipt_id: "r1", rescore_id: "x", factor_detail_sha256: hashPayload(dx) }]]), H()); + const b = lines.find((l) => l.id === "b"); + assert.equal(b.sealed, false, "x has no stored detail, so the link into b is not covered"); + assert.equal(b.changes, null); + }); + it("label uses the ledger timestamp, never created_utc", async () => { _resetProofCache(); - const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger(hashPayload(d0))) }, "r1"); - const fv = storedFactorView(hist({ factors: { spre: 0.99 } })); - sealRescoreChanges(fv.rescore_factor_changes, lr, proof); - assert.equal(fv.rescore_factor_changes[0].changes[0].after, 0.3, "unhashed top-level copy is ignored"); + const { rows, proof } = await receiptLedgerAndProof({ DB: fakeDb(L()) }, "r1"); + const lines = ledgerRescoreLines(H("FAKE1", "FAKE2"), rows, proof, "r1"); + const v = threadView({ ok: true, proof, receipt: { rescore_factor_changes: lines } }); + const labels = v.factors.filter((x) => x[0].startsWith("Rescore")).map((x) => x[0]); + assert.deepEqual(labels, ["Rescore t1 (ledger #2)", "Rescore t2 (ledger #3)"]); + assert.ok(!labels.join().includes("FAKE")); }); - it("edited factor_detail values: not covered", async () => { - _resetProofCache(); - const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger(hashPayload(d0))) }, "r1"); - const fv = storedFactorView(hist({ factor_detail: mkDetail({ spre: 0.99 }) })); - sealRescoreChanges(fv.rescore_factor_changes, lr, proof); - assert.equal(fv.rescore_factor_changes[0].sealed, false); - const v = threadView({ ok: true, proof, receipt: fv }); - assert.match(v.factors.find((x) => x[0] === "Rescore t2")[1], /\(stored data, not covered\)$/); - }); - it("previous snapshot unsealed: not covered even if own detail is sealed", async () => { +}); + +describe("reasoning seal", () => { + const row = { id: "r1", created_utc: "c", text_sha256: "x", label: "Triad", summary: "S", explanation: "E", score_before: 50, score_after: 31, residual: 69, isolated: 0 }; + const hash = (r) => hashPayload({ id: r.id, created_utc: r.created_utc, text_sha256: r.text_sha256, label: r.label, summary: r.summary, explanation: r.explanation, score_before: r.score_before, score_after: r.score_after, residual: r.residual, isolated: r.isolated }); + it("covered when receipt row hashes to sealed SUBMIT content_sha256", async () => { _resetProofCache(); - const { rows: lr, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger("0".repeat(64))) }, "r1"); - const fv = storedFactorView(hist({})); - sealRescoreChanges(fv.rescore_factor_changes, lr, proof); - assert.equal(fv.rescore_factor_changes[0].sealed, false); + const { rows, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledgerWith([["SUBMIT", { receipt_id: "r1", content_sha256: hash(row) }]])) }, "r1"); + assert.equal(reasoningSealed(row, rows, proof, hash), true); + assert.equal(reasoningSealed({ ...row, explanation: "edited" }, rows, proof, hash), false); + const v = threadView({ ok: true, proof, receipt: { summary: "S", reasoning_sealed: false } }); + assert.match(v.reasoning.find((x) => x[0] === "Reasoning seal")[1], /not covered/); }); }); diff --git a/workers/godlock-uk/src/factorDetail.js b/workers/godlock-uk/src/factorDetail.js index 9a0d21e..26a09aa 100644 --- a/workers/godlock-uk/src/factorDetail.js +++ b/workers/godlock-uk/src/factorDetail.js @@ -101,38 +101,7 @@ export function storedFactorView(rows) { const detail = snapInitial && snapInitial.factor_detail && snapInitial.factor_detail.schema === FACTOR_DETAIL_SCHEMA ? snapInitial.factor_detail : null; - const changes = []; - const detailOf = (snap) => (snap && snap.factor_detail && snap.factor_detail.schema === FACTOR_DETAIL_SCHEMA ? snap.factor_detail : null); - // Values come only from the hashed factor_detail, never the unhashed top-level triad_json.factors. - const valueOf = (d, key) => { - const f = d && d.factors ? d.factors[key] : null; - return f && f.value != null ? Number(f.value) : null; - }; - let prevDetail = detail; - let prevRef = { kind: "SUBMIT", id: null }; - for (const r of list) { - if (!r || r === initial) continue; - const d = detailOf(parse(r.triad_json)); - const entry = { id: r.id == null ? null : r.id, created_utc: r.created_utc, reason: r.reason, changes: null, note: null, sealed: false }; - Object.defineProperty(entry, "_detail", { value: d, enumerable: false }); - Object.defineProperty(entry, "_prevDetail", { value: prevDetail, enumerable: false }); - Object.defineProperty(entry, "_prevRef", { value: prevRef, enumerable: false }); - if (!prevDetail || !d) { - entry.note = NOT_STORED; - } else { - entry.changes = []; - for (const key of FACTOR_KEYS) { - const x = valueOf(prevDetail, key); - const y = valueOf(d, key); - if (x === y) continue; - entry.changes.push({ factor: key, before: x, after: y, delta: x != null && y != null ? Math.round((y - x) * 10000) / 10000 : null }); - } - } - changes.push(entry); - prevDetail = d; - prevRef = { kind: "RESCORE", id: entry.id }; - } - return { factor_detail: detail, factor_detail_note: detail ? null : NOT_STORED, rescore_factor_changes: changes }; + return { factor_detail: detail, factor_detail_note: detail ? null : NOT_STORED }; } export const NOT_SEALED_LABEL = "Per-factor detail is stored data. It is not covered by the seal."; @@ -161,19 +130,111 @@ export function factorDetailSealed(detail, ledgerRows, proof, match) { return false; } +function detailOf(json) { + const snap = parse(json); + return snap && snap.factor_detail && snap.factor_detail.schema === FACTOR_DETAIL_SCHEMA ? snap.factor_detail : null; +} + +function valueOf(d, key) { + const f = d && d.factors ? d.factors[key] : null; + return f && f.value != null ? Number(f.value) : null; +} + +function payloadOf(row) { + try { return JSON.parse(row.payload_json || "{}"); } catch { return null; } +} + +/** One ledger row is sealed for this detail: in the verified chain and its factor_detail_sha256 matches. */ +function rowSealed(row, detail, proof) { + if (!row || !detail || !proof || proof.status !== "verified") return false; + const p = payloadOf(row); + const want = p && p.factor_detail_sha256; + if (typeof want !== "string" || want.length !== 64) return false; + const e = (Array.isArray(proof.entries) ? proof.entries : []) + .find((x) => Number(x.sequence) === Number(row.sequence) && x.entry_hash === row.entry_hash); + if (!e || !e.hash_ok || !e.in_verified_range || !e.matches_checkpoint) return false; + return hashPayload(detail) === want; +} + /** - * A rescore line is covered only if its own detail is sealed by its RESCORE - * entry AND the previous detail (SUBMIT or prior RESCORE) is sealed too. + * Rescore lines ordered and paired by the hashed ledger, never by unhashed + * receipt_rescores fields. history = receipt_rescores rows (any order; used + * only to look up stored detail by id). ledgerRows = this receipt's ledger rows. + * Line i compares the detail sealed by RESCORE row i with the detail sealed by + * the immediately preceding SUBMIT/RESCORE row for this receipt_id (adjacent in + * the filtered seq order, so nothing can sit between them). Labels and reason + * come from the hashed ledger row. Rescores with no ledger row are listed last + * as not covered. */ -export function sealRescoreChanges(changes, ledgerRows, proof) { - const rescoreMatch = (id) => (row, payload) => String(row.action) === "RESCORE" && payload && payload.rescore_id === id; - for (const c of Array.isArray(changes) ? changes : []) { - const own = !!c.id && factorDetailSealed(c._detail, ledgerRows, proof, rescoreMatch(c.id)); - const ref = c._prevRef || {}; - const prev = ref.kind === "SUBMIT" - ? factorDetailSealed(c._prevDetail, ledgerRows, proof) - : (!!ref.id && factorDetailSealed(c._prevDetail, ledgerRows, proof, rescoreMatch(ref.id))); - c.sealed = own && prev; +export function ledgerRescoreLines(history, ledgerRows, proof, receiptId) { + const hist = Array.isArray(history) ? history : []; + const byId = new Map(); + let initialDetail = null; + for (const h of hist) { + if (!h) continue; + if (h.reason === "initial") initialDetail = detailOf(h.triad_json); + else if (h.id != null) byId.set(String(h.id), h); } - return changes; + const rid = String(receiptId || ""); + const chain = (Array.isArray(ledgerRows) ? ledgerRows : []) + .map((row) => ({ row, p: payloadOf(row) })) + .filter(({ row, p }) => p && (!rid || p.receipt_id === rid) && (row.action === "SUBMIT" || row.action === "RESCORE")) + .sort((x, y) => Number(x.row.sequence) - Number(y.row.sequence)); + const lines = []; + const seen = new Set(); + let prev = null; // { row, detail } + for (const { row, p } of chain) { + if (row.action === "SUBMIT") { prev = { row, detail: initialDetail }; continue; } + const id = p.rescore_id == null ? null : String(p.rescore_id); + const h = id ? byId.get(id) : null; + if (id) seen.add(id); + const detail = h ? detailOf(h.triad_json) : null; + const line = { + id, + ledger_seq: Number(row.sequence), + ledger_timestamp_utc: row.timestamp_utc, + reason: typeof p.reason === "string" ? p.reason : null, + changes: null, + note: null, + sealed: false, + }; + if (!prev || !prev.detail || !detail) { + line.note = NOT_STORED; + } else { + line.changes = []; + for (const key of FACTOR_KEYS) { + const x = valueOf(prev.detail, key); + const y = valueOf(detail, key); + if (x === y) continue; + line.changes.push({ factor: key, before: x, after: y, delta: x != null && y != null ? Math.round((y - x) * 10000) / 10000 : null }); + } + } + line.sealed = !!prev && rowSealed(row, detail, proof) && rowSealed(prev.row, prev.detail, proof); + lines.push(line); + prev = { row, detail }; + } + for (const [id] of byId) { + if (seen.has(id)) continue; + lines.push({ id, ledger_seq: null, ledger_timestamp_utc: null, reason: null, changes: null, note: "no ledger entry for this rescore", sealed: false }); + } + return lines; +} + +/** + * Summary/explanation are covered only when the SUBMIT row is in the verified + * chain and its content_sha256 equals the hash recomputed from the stored + * receipt row (hashFn = engine.hashReceipt). Else false. + */ +export function reasoningSealed(receiptRow, ledgerRows, proof, hashFn) { + if (!receiptRow || !proof || proof.status !== "verified" || typeof hashFn !== "function") return false; + const sub = (Array.isArray(ledgerRows) ? ledgerRows : []).find((r) => String(r.action) === "SUBMIT"); + if (!sub) return false; + const p = payloadOf(sub); + if (!p || typeof p.content_sha256 !== "string") return false; + const e = (Array.isArray(proof.entries) ? proof.entries : []) + .find((x) => Number(x.sequence) === Number(sub.sequence) && x.entry_hash === sub.entry_hash); + if (!e || !e.hash_ok || !e.in_verified_range || !e.matches_checkpoint) return false; + let h; + try { h = hashFn(receiptRow); } catch { return false; } + return h === p.content_sha256; } diff --git a/workers/godlock-uk/src/index.js b/workers/godlock-uk/src/index.js index bcbbb61..8f0a6c0 100644 --- a/workers/godlock-uk/src/index.js +++ b/workers/godlock-uk/src/index.js @@ -24,7 +24,7 @@ import { donateDoc, DONATE_RAILS, donateQrIdFromPath } from "./donate.js"; import { handleRuntimeRoot, isRuntimeRequest, runtimeCors } from "./runtimeRoot.js"; import { appendLedger, verifyLedger, ledgerEntriesForId, sha256hex, publicEntry, hashPayload } from "./ledger.js"; import { receiptLedgerAndProof } from "./receiptProof.js"; -import { factorDetail, storedFactorView, factorDetailSealed, sealRescoreChanges } from "./factorDetail.js"; +import { factorDetail, storedFactorView, factorDetailSealed, ledgerRescoreLines, reasoningSealed } from "./factorDetail.js"; import { robotsTxt, sitemapXml, citeDoc, llmsDoc, aiDoc, siteOpenApi, mcpDiscoveryDoc, BANNER, DOWNLOAD, DOWNLOAD_STATS, DOWNLOAD_COUNT, GITHUB, AUTHOR, indexNowKeyBody, INDEXNOW_KEY_PATH, INDEXNOW_WELL_KNOWN_PATH, @@ -1288,8 +1288,8 @@ export default { ok: true, receipt: (() => { const fv = storedFactorView(row.rescores); - sealRescoreChanges(fv.rescore_factor_changes, ledgerRows, proof); - return { ...publicPayload(row), ...fv, factor_detail_sealed: factorDetailSealed(fv.factor_detail, ledgerRows, proof) }; + fv.rescore_factor_changes = ledgerRescoreLines(row.rescores, ledgerRows, proof, id); + return { ...publicPayload(row), ...fv, factor_detail_sealed: factorDetailSealed(fv.factor_detail, ledgerRows, proof), reasoning_sealed: reasoningSealed(row, ledgerRows, proof, hashReceipt) }; })(), ledger: entries, proof, diff --git a/workers/godlock-uk/src/ui.js b/workers/godlock-uk/src/ui.js index c4e50b9..44cbb51 100644 --- a/workers/godlock-uk/src/ui.js +++ b/workers/godlock-uk/src/ui.js @@ -533,6 +533,9 @@ export function threadView(j) { if (typeof r.summary === "string" && r.summary) out.reasoning.push(["Summary", r.summary]); if (typeof r.explanation === "string" && r.explanation) out.reasoning.push(["Reasoning", r.explanation]); if (!out.reasoning.length) out.reasoning.push(["Reasoning", "No reasoning was recorded for this receipt."]); + out.reasoning.push(["Reasoning seal", r.reasoning_sealed === true + ? "Summary and reasoning are covered by the seal: the receipt row hashes to content_sha256 in the sealed SUBMIT entry." + : "Summary and reasoning are stored data. They are not covered by the seal."]); var fmt = function (v) { return v === null || v === undefined ? "none" : (typeof v === "object" ? JSON.stringify(v) : String(v)); }; var fd = r.factor_detail; out.factors.push(["Seal coverage", r.factor_detail_sealed === true @@ -558,7 +561,8 @@ export function threadView(j) { var rc = Array.isArray(r.rescore_factor_changes) ? r.rescore_factor_changes : []; for (var c = 0; c < rc.length; c++) { var e = rc[c] || {}; - var label = "Rescore " + fmt(e.created_utc); + var label = e.ledger_timestamp_utc ? "Rescore " + e.ledger_timestamp_utc + " (ledger #" + e.ledger_seq + ")" : "Rescore (no ledger entry)"; + if (e.note && e.note !== "per-factor detail not stored for this receipt" && !Array.isArray(e.changes)) { out.factors.push([label, e.note + " (stored data, not covered)"]); continue; } var sfx = e.sealed === true ? " (covered by seal)" : " (stored data, not covered)"; if (!Array.isArray(e.changes)) { out.factors.push([label, "per-factor detail not stored for this receipt" + sfx]); continue; } if (!e.changes.length) { out.factors.push([label, "no factor value changed" + sfx]); continue; } diff --git a/workers/godlock-uk/thread-expand.test.mjs b/workers/godlock-uk/thread-expand.test.mjs index db3a675..6b93c8a 100644 --- a/workers/godlock-uk/thread-expand.test.mjs +++ b/workers/godlock-uk/thread-expand.test.mjs @@ -127,7 +127,8 @@ describe("thread expand view", () => { it("verified proof renders hashes, seal and verify link", () => { const v = threadView({ ok: true, receipt: { summary: "S", explanation: "E" }, proof: { status: "verified", receipt_hash: "h1", prev_hash: "h0", seal: "h9", timestamp_utc: "t", verify_url: "/verify" } }); assert.equal(v.status, "verified"); - assert.deepEqual(v.reasoning, [["Summary", "S"], ["Reasoning", "E"]]); + assert.deepEqual(v.reasoning.slice(0, 2), [["Summary", "S"], ["Reasoning", "E"]]); + assert.deepEqual(v.reasoning[2], ["Reasoning seal", "Summary and reasoning are stored data. They are not covered by the seal."]); const m = Object.fromEntries(v.receipt); assert.equal(m["Seal (verified ledger head)"], "h9"); assert.equal(m.Verify, "/verify"); });