agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
561 lines • 29.6 kB
JavaScript
/**
* auditExport — tamper-evident audit bundle (backlog #20, compliance
* wedge item 2; pairs with #19's `otelObservability` GenAI spans).
*
* Consumes the typed `agentfootprint.*` event stream and accumulates an
* append-only, HASH-CHAINED record log: every record carries the SHA-256
* of its own canonical serialization plus the hash of the previous
* record. Flipping a single byte anywhere in an exported bundle makes
* `verifyAuditBundle` name the exact record that broke. The log serves
* the automatic event recording EU AI Act Art. 12 asks of high-risk
* systems (the events the system logged, in order). The tamper evidence
* — showing the log is unmodified since capture — is this library's
* addition: Art. 12 does not require it.
*
* Pattern: Observability strategy (one purpose — chain accumulation)
* + pure offline verifier.
* Role: Outer ring (Hexagonal). Attach via
* `agent.enable.observability({ strategy: auditExport() })`.
* Emits: nothing — terminal sink.
*
* ## What lands in the chain
*
* One record per typed event, in dispatch order: decisions
* (`agent.route_decided`, `composition.route_decided` incl. decide()
* evidence), tool calls (`stream.tool_start/_end`), validation
* rejections (#9), permission verdicts and halts, credential lifecycle,
* costs, errors, skill/memory/context activity. Each new `meta.runId`
* is anchored by a GENESIS record (`audit.genesis`) carrying the runId,
* the agent identity, and library versions — runs chain back-to-back in
* one log, so silently DROPPING a whole run breaks the chain too.
*
* A call a paused batch never dispatched (9.113.0) lands as its two
* brackets, each carrying `notDispatched: { pausedCall: { toolCallId,
* toolName } }` and neither carrying `error` — identifiers only. Bounded mode
* checks each field by its declared shape; verbatim mode sanitizes the payload.
* The chained record therefore says the call never ran — neither that it
* succeeded nor that it failed; the bounded `result` (`[type: string]`) never
* had to say it.
*
* High-volume content deltas (`stream.token`, `stream.thinking_delta`)
* are excluded by default (`includeTokenEvents: true` to include).
*
* ## Record / bundle schema
*
* ```
* AuditRecord = { seq, timestamp, eventType, payload, meta, prevHash, hash }
* hash = SHA-256 hex over canonicalJson(record minus `hash`)
* prevHash = previous record's `hash` (ZERO_HASH at chain start)
* AuditBundle = { header, records, finalHash }
* header = { format, hashAlgorithm, canonicalization, chainHead,
* firstSeq, recordCount, exportedAt, library }
* ```
*
* Canonicalization is `afp-cjson/1` (see `lib/canonicalJson.ts` — those
* rules ARE the contract; the header names them so independent
* verifiers can re-implement byte-exactly).
*
* ## Persistence + long runs
*
* Persistence is the CONSUMER's job — the bundle is plain JSON
* (`JSON.stringify(strategy.bundle())`, store anywhere). For long runs,
* `drain()` returns the records accumulated since the last drain while
* keeping the chain intact ACROSS drains: each segment's
* `header.chainHead` equals the previous segment's `finalHash`, so
* `verifyAuditBundle([seg1, seg2, ...])` re-verifies the concatenation
* end-to-end.
*
* ## PII discipline (mirrors #19's otelObservability)
*
* Payloads enter records through a bounding layer: every registered event
* has a shape that names every field of its payload (`auditShapes.ts`, total
* over the event registry and exhaustive by type, built from the combinators
* in `auditBounding.ts`). In the default `payloadMode: 'bounded'` a record
* payload keeps the run's STRUCTURE and never its CONTENT — every string or
* value the run composed from what it read or produced, whoever wrote it —
* except what is named below under "What a bounded record still carries".
* Bounding is CLOSED by default: a field a shape does not name, a value that
* is not the declared shape, and every field of an event type the registry
* does not declare (a consumer's own `emit`), is a marker. Some of what that
* bounds:
*
* - tool args → `'[keys: …]'` (top-level key NAMES only);
* `tool_end`'s `changedArgKeys` is names already
* - tool results → `'[type: …]'` (typeof only) — `result` and,
* when a rule changed it, `modelResult` alike
* - an attached script's program (`turn_end.scripts.shown[].text`)
* → `'[N chars]'`; checked labels, ids, counts and
* rendering-boundary flags retain their shapes
* - an evaluation's `explanation` → `'[N chars]'` (a judge's reason can
* quote the answer it graded)
* - userPrompt / LLM content / thinking blocks / history
* → `'[N chars]'` / `'[N messages]'` markers
* - content PREVIEWS (`contentSummary` on context/memory events,
* `rawContent`, `resultSummary`, `droppedSummaries`) → markers
* (for short content a preview IS the content; `contentHash`
* stays — it links identical content without echoing it)
* - error MESSAGE strings (`error`, `errorMessage`, `lastError`,
* `rawOutput`) → `'[N chars]'` (messages can echo values)
* - free-form Records (`questionPayload`, `resumeInput`, risk/eval
* `evidence`, memory `scoreEvidence`) → `'[keys: …]'`
* - a typed answer's limits and negatives on `turn_end` (`answerCoverage`,
* `answerNegatives`) → the same shape, every sentence and value a
* `'[N chars]'` marker: a coverage item's `what` / `why` / `short`, a
* declared period's instants, an in-progress item's prose, an assumed
* `value`, a negative's `statement` and `about`. The tool and argument
* names, call ids, counts, kinds, codes and verdicts stay; a field the
* shape does not name is a marker too, so one added later is bounded
* until it is classified
* - every other event whose content sits beside structure keeps its
* shape, each content leaf a marker: a tool's absence, coverage
* and described-result words and data (`tools.absent`,
* `tools.coverage_declared`, `tools.semantics_declared`), the evidence
* gate's values, an external ground's value, the message text a routing
* match quotes (`map.engaged`, `map.parked`, `skill.turn_routed`,
* `context.evaluated`), a validation issue's quoted value, parser and
* fallback messages and previews, a tool's progress payload and a
* check-in's evidence pack, a person's note, a middleware's reason or
* question, the model's own words (`skill.step_skipped`, `agent.handoff`),
* the opt-in assembled system prompt, a retrieved passage's heading, an
* integrity finding's witnessed values and message, a tool effect's
* reason, a pause's own reason, an artifact's label and refusal detail, a
* fail-fast reason, a check's `rationale` and `tellLLM`, a fallback's or a
* credential failure's message, and a router's own reason
*
* What the shapes keep is checked structure: a number, boolean or string
* must arrive as that kind, and a closed enum must be one of its declared
* words. Every container is classified field by field, even if its declared
* type contains only counts. Unknown children and wrong kinds are markers.
* Strings are capped at 256 characters and lists at 32 items. This retains
* ids, names, kinds, codes, telemetry tags, verdicts, counts, library times
* and the words the app declared up front (labels, descriptions, step notes,
* hints, display names, rule ids). The type walker and runtime damage sweep
* check both halves; `auditKeptByContract.ts` names the exceptions below.
*
* What a bounded record still carries, by contract:
*
* - each record's `meta`: run and stage ids, paths, turn and iteration
* indices and — when the caller named them — `sessionId`, `principal`
* and `tenant`, the who of an audit record;
* - decision evidence as the engine summarized it: `decide()` evidence on
* `composition.route_decided` and a skill guard's conditions on
* `context.evaluated` — rule labels, operators, thresholds and a summary
* of each value a rule read. It is redaction-aware (under an agent's
* `redact` a selected key is the placeholder); otherwise the summary
* shows the value;
* - a policy's telemetry `reason` tag, and the reason word an app attaches
* to a lifecycle event it emits itself (`skill.deactivated`,
* `tools.offered`, `permission.gate_closed`): the app's own words, as
* written — an app that writes a run's value into one carries it;
* - a `tryInsteadTool` name, only once this strategy has seen that tool run.
*
* Sanitizing caps a string's length; it does not hide what the string says.
*
* `payloadMode: 'verbatim'` embeds full payloads (still
* JSON-sanitized). For Art. 12 completeness on an access-controlled
* store that is often the point — but the bundle then carries prompts,
* tool args/results and model output. Treat it as PII-bearing. An agent
* built with `Agent.create({ redact })` serves every event before any
* strategy sees it, so this one receives — and chains — the placeholder
* wherever the policy selected a value (a selected key at any depth of a
* payload; a whole payload whose event NAME `emitPatterns` selects). The
* policy selects by name, never by content: with no policy, or under a name
* it does not select, the value arrives as it was.
*
* ## Tamper-EVIDENT, not tamper-PROOF (honest threat model)
*
* The chain proves INTERNAL consistency: any partial modification —
* edit, insert, delete, reorder, drop-a-run — is detected and named.
* It does NOT prove provenance: an adversary holding the only copy can
* recompute every hash from the mutation onward and present a
* self-consistent forgery. For non-repudiation, anchor `finalHash`
* externally as part of your retention process (write-once/WORM store,
* signed log, RFC 3161 timestamping, or simply a second party) — then
* a whole-suffix recomputation no longer matches the anchor.
*
* ## Runtime requirements
*
* Hashing uses `node:crypto` (`createHash('sha256')`) — zero new
* dependencies, imported lazily at first use (same gating as the
* optional vendor SDKs in this folder, so merely importing this module
* stays browser-safe). `auditExport` and `verifyAuditBundle` therefore
* run anywhere `node:crypto` exists: Node ≥ 20, Bun, Deno,
* edge runtimes with Node compat (e.g. Cloudflare `nodejs_compat`).
* In a browser there is no SYNC SHA-256 (WebCrypto is async-only), so
* both throw a descriptive error — verify server-side, or re-implement
* verification from the documented contract (it is pure: recompute
* SHA-256 over `afp-cjson/1` canonicalization and walk the chain).
*
* @example Capture → export → verify
* ```ts
* import { auditExport, verifyAuditBundle } from 'agentfootprint/observe';
*
* const audit = auditExport({ agent: 'loan-officer' });
* const stop = agent.enable.observability({ strategy: audit });
* await agent.run({ message: 'assess application A-17' });
* stop();
*
* const bundle = audit.bundle(); // JSON-serializable
* await fs.writeFile('run.audit.json', JSON.stringify(bundle));
*
* const check = verifyAuditBundle(bundle); // offline — no agent needed
* // check.valid === true; tamper with one byte → { valid: false, brokenAt: <seq> }
* ```
*/
import { canonicalJson, CANONICAL_JSON_VERSION } from '../../lib/canonicalJson.js';
import { lazyRequire } from '../../lib/lazyRequire.js';
import { libraryVersion } from '../../lib/libraryVersion.js';
import { blocksMarker, charsMarker, createToolRunTracker, keysMarker, messagesMarker, sanitizeJson, summariesMarker, typeMarker, unknownEventShape, } from './auditBounding.js';
import { boundAnswerCoverage, boundAnswerNegatives, boundMeta, boundScripts, eventShapeTable, } from './auditShapes.js';
// ─── Public types ─────────────────────────────────────────────────────
/** SHA-256 of "nothing" — the `prevHash` of the first record in a
* chain and the `chainHead` of a chain's first segment. */
export const AUDIT_ZERO_HASH = '0'.repeat(64);
/** `eventType` of the per-run genesis record. Deliberately OUTSIDE the
* `agentfootprint.*` registry namespace — it is a chain-level record,
* not a dispatched event (#20 ships zero new typed events). */
export const AUDIT_GENESIS_EVENT_TYPE = 'audit.genesis';
/** Format identifier carried on every bundle header. */
export const AUDIT_BUNDLE_FORMAT = 'agentfootprint.audit/1';
let cryptoModule;
function resolveCrypto() {
if (cryptoModule)
return cryptoModule;
try {
cryptoModule = lazyRequire('node:crypto');
}
catch {
throw new Error('auditExport/verifyAuditBundle require `node:crypto` for synchronous SHA-256 ' +
'(available in Node ≥ 20, Bun, Deno, and edge runtimes with Node compat). ' +
'Browsers expose only async WebCrypto — capture/verify audit bundles server-side.');
}
if (typeof cryptoModule.createHash !== 'function') {
throw new Error('auditExport: `node:crypto` resolved but `createHash` is missing.');
}
return cryptoModule;
}
function sha256Hex(text) {
return resolveCrypto().createHash('sha256').update(text, 'utf8').digest('hex');
}
// ─── Bounding layer (payloadMode: 'bounded') ─────────────────────────
/**
* The conversation fields the bounded mode replaces WHOLE, per event type — a
* DECLARATION. Bounding itself is every event's shape (`auditShapes.ts` ·
* `eventShapes`), which applies these same rule functions to these same fields
* (pinned by `test/type-regressions/AuditBoundingDepth.test.ts`); this table
* names them so the redaction vocabulary can be pinned against them
* (`boundedContentFieldNames`).
*/
const BOUND_FIELDS = {
'agentfootprint.agent.turn_start': { userPrompt: charsMarker },
// A typed answer's limits and negatives hold tool-written sentences, assumed
// values and the populations a search covered BESIDE the record's own words:
// each is bounded at its content leaves and keeps its shape.
'agentfootprint.agent.turn_end': {
finalContent: charsMarker,
answerCoverage: boundAnswerCoverage,
answerNegatives: boundAnswerNegatives,
},
'agentfootprint.agent.iteration_end': { history: messagesMarker },
'agentfootprint.agent.output_schema_validation_failed': { rawOutput: charsMarker },
'agentfootprint.stream.llm_end': { content: charsMarker },
'agentfootprint.stream.token': { content: charsMarker },
'agentfootprint.stream.thinking_delta': { content: charsMarker },
'agentfootprint.stream.thinking_end': { blocks: blocksMarker },
'agentfootprint.stream.tool_start': { args: keysMarker },
// `modelResult` (what a rule let the model read, or the envelope without
// its record-only `short` / `kind`) is the same content as `result`, from
// the other side of a rule or that strip — bounded the same way.
// `changedArgKeys` is names only and rides verbatim, like `args`' keys.
'agentfootprint.stream.tool_end': { result: typeMarker, modelResult: typeMarker },
// contentSummary is a raw-content PREVIEW (not a redacted summary) —
// for short content it IS the content, so it is bounded like content.
// `contentHash` stays verbatim: it links identical injections across
// records without echoing a byte of them.
'agentfootprint.context.injected': { rawContent: charsMarker, contentSummary: charsMarker },
'agentfootprint.context.slot_composed': { droppedSummaries: summariesMarker },
'agentfootprint.composition.merge_end': { resultSummary: charsMarker },
'agentfootprint.memory.attached': { contentSummary: charsMarker },
'agentfootprint.memory.written': { contentSummary: charsMarker },
'agentfootprint.pause.request': { questionPayload: keysMarker },
'agentfootprint.pause.resume': { resumeInput: keysMarker },
// `explanation` is a judge's reason — it can quote the answer it graded
// (`EvalScorePayload.explanation` says CONTENT). `label` is a vocabulary
// word by contract and stays verbatim.
'agentfootprint.eval.score': { evidence: keysMarker, explanation: charsMarker },
'agentfootprint.memory.strategy_applied': { scoreEvidence: keysMarker },
};
/**
* The content field names the bounded mode replaces, across every event type.
* Pinned against the agent's redaction vocabulary
* (`redaction/conversation.ts` · `conversationRedaction`): a field this table
* calls content is a name the conversation travels under, and the test fails
* when the two disagree.
*
* @internal
*/
export function boundedContentFieldNames() {
return [...new Set(Object.values(BOUND_FIELDS).flatMap((rules) => Object.keys(rules)))];
}
/**
* The rules `BOUND_FIELDS` declares, by event type and field — what the pin
* holds every event's shape to.
*
* @internal
*/
export function boundedFieldRules() {
return BOUND_FIELDS;
}
/** Content leaves inside a field that also carries labels, ids and counts.
* The function is the same rule the event shape applies, never a second sanitizer. */
const BOUND_LEAVES = {
'agentfootprint.agent.turn_end': {
scripts: { bound: boundScripts, leaves: ['shown[].text'] },
},
};
/** The bounded content leaves, pinned against the redaction vocabulary's value-kind rule.
* @internal */
export function boundedContentLeaves() {
return Object.entries(BOUND_LEAVES).flatMap(([eventType, fields]) => Object.entries(fields).flatMap(([field, rule]) => rule.leaves.map((leaf) => `${eventType} · ${field}.${leaf}`)));
}
// ─── Strategy factory ────────────────────────────────────────────────
/** Excluded by default — high-volume content deltas. */
const TOKEN_EVENT_TYPES = new Set([
'agentfootprint.stream.token',
'agentfootprint.stream.thinking_delta',
]);
export function auditExport(opts = {}) {
const payloadMode = opts.payloadMode ?? 'bounded';
const includeTokenEvents = opts.includeTokenEvents === true;
const version = libraryVersion();
// Chain state — survives drains; one chain per strategy instance.
let nextSeq = 0;
let lastHash = AUDIT_ZERO_HASH;
let segmentHead = AUDIT_ZERO_HASH;
let segmentFirstSeq = 0;
let retained = [];
const seenRunIds = new Set();
let stopped = false;
// Bounded mode: the tools this strategy has seen run (a `tryInsteadTool`
// name is kept only for one of them) and the whole-event shapes — a `Map`,
// because an event's name is the emitter's to choose.
const toolRuns = createToolRunTracker();
const shapes = eventShapeTable(toolRuns.ran);
/** An event's payload in bounded mode: its shape — closed by default, an
* event type the registry does not declare (any name at all) included. */
function boundEvent(eventType, payload) {
const shape = shapes.get(eventType) ?? unknownEventShape;
return shape(payload);
}
/** A record's meta: its shape in bounded mode (`EventMeta`, each field
* checked), sanitized in verbatim mode; anything but an object, none. */
function recordMetaOf(metaRest) {
const bounded = payloadMode === 'bounded' ? boundMeta(metaRest) : sanitizeJson(metaRest);
return bounded !== null && typeof bounded === 'object' && !Array.isArray(bounded)
? bounded
: {};
}
function append(eventType, timestamp, payload, meta) {
const preimage = { seq: nextSeq, timestamp, eventType, payload, meta, prevHash: lastHash };
const hash = sha256Hex(canonicalJson(preimage));
retained.push({ ...preimage, hash });
lastHash = hash;
nextSeq += 1;
}
function appendGenesis(runId, timestamp) {
append(AUDIT_GENESIS_EVENT_TYPE, timestamp, {
runId,
...(opts.agent !== undefined && { agent: opts.agent }),
library: { name: 'agentfootprint', version },
...(opts.versions !== undefined && { versions: sanitizeJson(opts.versions) }),
payloadMode,
}, { runId });
}
function makeBundle() {
return {
header: {
format: AUDIT_BUNDLE_FORMAT,
hashAlgorithm: 'sha-256',
canonicalization: CANONICAL_JSON_VERSION,
chainHead: segmentHead,
firstSeq: segmentFirstSeq,
recordCount: retained.length,
exportedAt: Date.now(),
library: { name: 'agentfootprint', version },
},
records: [...retained],
finalHash: lastHash,
};
}
return {
name: 'audit',
capabilities: { events: true },
/** Attach-time check (the `validate()` contract) — fail fast where
* `node:crypto` is unavailable instead of on the first event. */
validate() {
resolveCrypto();
},
exportEvent(event) {
if (stopped)
return;
if (!includeTokenEvents && TOKEN_EVENT_TYPES.has(event.type))
return;
// Real dispatcher envelopes carry meta (bridge/eventMeta.ts).
// Hand-fed events without it are still recorded — an audit sink
// must never silently drop — under the 'unattributed' run anchor.
const meta = event.meta;
const runId = typeof meta?.runId === 'string' ? meta.runId : 'unattributed';
const timestamp = typeof meta?.wallClockMs === 'number' ? meta.wallClockMs : Date.now();
if (!seenRunIds.has(runId)) {
seenRunIds.add(runId);
appendGenesis(runId, timestamp);
}
// wallClockMs lives on `timestamp`; the rest of the meta rides along
// (runtimeStageId, paths, turn/iter indices, …) — by its shape in bounded
// mode, sanitized in verbatim mode.
const { wallClockMs: _lifted, ...metaRest } = meta ?? {};
void _lifted;
const recordMeta = { ...recordMetaOf(metaRest), runId };
toolRuns.observe(event.type, event.payload);
const payload = payloadMode === 'bounded'
? boundEvent(event.type, event.payload)
: sanitizeJson(event.payload);
append(event.type, timestamp, payload, recordMeta);
},
flush() {
// In-memory sink — nothing to flush. Export via bundle()/drain().
},
stop() {
// Stop OBSERVING; never destroy collected evidence — consumers
// drain/bundle after stop.
stopped = true;
},
bundle() {
return makeBundle();
},
drain() {
const segment = makeBundle();
segmentHead = lastHash;
segmentFirstSeq = nextSeq;
retained = [];
return segment;
},
recordCount() {
return retained.length;
},
};
}
// ─── Offline verification (pure — no agent, no strategy) ─────────────
/**
* Recompute the hash chain of a bundle (or of consecutive drained
* segments, in order) and report the exact record where integrity
* breaks. Pure function over JSON data — runs offline, long after the
* run, with no agent and no strategy instance.
*
* Checks, in order, per segment:
* 1. header format / algorithm / canonicalization are supported
* 2. `recordCount` matches `records.length`
* 3. segment continuity (`chainHead`/`firstSeq` extend the previous
* segment's `finalHash`/seq range)
* 4. per record: `seq` is contiguous, `prevHash` links the previous
* record, and SHA-256 over the canonical preimage (the record
* minus `hash` — so ADDED fields are caught too) matches `hash`
* 5. `finalHash` equals the last record's hash
*/
export function verifyAuditBundle(input) {
const segments = Array.isArray(input)
? input
: [input];
let recordsChecked = 0;
let expectedChainHead;
let expectedFirstSeq;
const fail = (reason, brokenAt) => ({
valid: false,
recordsChecked,
...(brokenAt !== undefined && { brokenAt }),
reason,
});
if (segments.length === 0)
return fail('no bundles supplied');
for (let s = 0; s < segments.length; s++) {
const segment = segments[s];
const where = segments.length > 1 ? ` (segment ${s})` : '';
if (segment === undefined || typeof segment !== 'object') {
return fail(`bundle is not an object${where}`);
}
const { header, records, finalHash } = segment;
if (header === undefined || !Array.isArray(records) || typeof finalHash !== 'string') {
return fail(`bundle missing header/records/finalHash${where}`);
}
if (header.format !== AUDIT_BUNDLE_FORMAT) {
return fail(`unsupported format '${String(header.format)}'${where}`);
}
if (header.hashAlgorithm !== 'sha-256') {
return fail(`unsupported hashAlgorithm '${String(header.hashAlgorithm)}'${where}`);
}
if (header.canonicalization !== CANONICAL_JSON_VERSION) {
return fail(`unsupported canonicalization '${String(header.canonicalization)}'${where}`);
}
if (header.recordCount !== records.length) {
return fail(`recordCount ${String(header.recordCount)} does not match ${records.length} records${where}`);
}
if (expectedChainHead !== undefined && header.chainHead !== expectedChainHead) {
return fail(`segment discontinuity — chainHead does not match previous segment's finalHash${where}`, header.firstSeq);
}
if (expectedFirstSeq !== undefined && header.firstSeq !== expectedFirstSeq) {
return fail(`segment discontinuity — firstSeq ${String(header.firstSeq)}, expected ${expectedFirstSeq}${where}`, expectedFirstSeq);
}
// Head-truncation invariant (adversarial-review finding): a segment
// claiming to be the chain START (firstSeq 0) MUST anchor on the zero
// hash, and a segment anchored on the zero hash MUST claim seq 0 —
// both directions. Without this, an attacker could drop the first K
// records (genesis included — agent identity, versions, payload-mode
// declaration) and forge `chainHead` from the survivor's prevHash;
// `finalHash` is untouched by that attack, so external finalHash
// anchoring alone would NOT catch it. With the invariant, a head-
// truncated forgery must declare firstSeq > 0 — visibly NOT a chain
// start — and auditors reject leading segments missing seq 0.
if (header.firstSeq === 0 && header.chainHead !== AUDIT_ZERO_HASH) {
return fail(`head-truncation guard — firstSeq 0 requires chainHead to be the zero hash${where}`, 0);
}
if (header.chainHead === AUDIT_ZERO_HASH && header.firstSeq !== 0) {
return fail(`head-truncation guard — zero-hash chainHead requires firstSeq 0, got ${String(header.firstSeq)}${where}`, header.firstSeq);
}
let prevHash = header.chainHead;
for (let i = 0; i < records.length; i++) {
const record = records[i];
const expectedSeq = header.firstSeq + i;
if (record === null || typeof record !== 'object') {
return fail(`record at seq ${expectedSeq} is not an object`, expectedSeq);
}
if (record.seq !== expectedSeq) {
return fail(`sequence mismatch — record carries seq ${String(record.seq)}, expected ${expectedSeq}`, expectedSeq);
}
if (record.prevHash !== prevHash) {
return fail(`chain broken — prevHash does not match the preceding record's hash`, expectedSeq);
}
let recomputed;
try {
const { hash: _stored, ...preimage } = record;
void _stored;
recomputed = sha256Hex(canonicalJson(preimage));
}
catch (err) {
return fail(`record not canonicalizable (${err instanceof Error ? err.message : String(err)})`, expectedSeq);
}
if (recomputed !== record.hash) {
return fail(`hash mismatch — record content does not match its hash (tampered or corrupted)`, expectedSeq);
}
prevHash = record.hash;
recordsChecked += 1;
}
if (finalHash !== prevHash) {
const lastSeq = header.firstSeq + records.length - 1;
return fail(`finalHash does not match the last record's hash${where}`, records.length > 0 ? lastSeq : header.firstSeq);
}
expectedChainHead = finalHash;
expectedFirstSeq = header.firstSeq + records.length;
}
return { valid: true, recordsChecked };
}
//# sourceMappingURL=audit.js.map