/** * Receipt -- chain validation and envelope construction. * * Salvaged from `@kit/core`. * * @module */ import { Effect } from 'effect'; import { ParseError } from '@czap/error'; import type { HLC } from './brands.js'; import { TypedRef as TypedRefModule, type TypedRef } from './typed-ref.js'; import { HLC as HLCOps } from './hlc.js'; /** The logical entity a receipt describes: an effect, a run, an artifact, or an intent. */ export interface ReceiptSubject { readonly type: 'effect' | 'run' | 'artifact' | 'intent'; readonly id: string; } /** * Single link in a receipt chain: timestamped, content-addressed, and linked * to its predecessor(s). Merge envelopes carry an array of `previous` hashes; * optionally MAC-signed via `Receipt.macEnvelope`. */ export interface ReceiptEnvelope { readonly kind: string; /** * Causal clock (CUT B2): an {@link HLC}, NOT a wall-clock string. It is * INCLUDED in `hashEnvelope` and monotonic-validated by `validateChain` * (`hlc_not_increasing`) — i.e. identity- and ordering-bearing. Not * interchangeable with a `WallClockTimestamp` (the volatile, identity-irrelevant * ISO stamp on command/CLI receipts). */ readonly timestamp: HLC; readonly subject: ReceiptSubject; readonly payload: TypedRef.Shape; readonly hash: string; readonly previous: string | readonly string[]; readonly signature?: string; } /** Structured failure returned by `Receipt.validateChainDetailed`. */ export type ChainValidationError = | { readonly type: 'not_genesis'; readonly index: 0 } | { readonly type: 'hash_mismatch'; readonly index: number; readonly computed: string; readonly stored: string } | { readonly type: 'chain_break'; readonly index: number; readonly expected: string; readonly actual: string } | { readonly type: 'hlc_not_increasing'; readonly index: number } | { readonly type: 'checkpoint_invalid'; readonly reason: string }; /** * Options that let a chain be validated as a COMPACTED TAIL instead of a full * history (see `DAG.checkpoint`). Optional everywhere — omitting them is the * back-compat genesis-rooted check. * * - `base`: a checkpoint watermark hash. The index-0 genesis predicate widens to * accept `previous === base`, so a retained tail validates without its dropped * prefix. * - `checkpoint`: the genesis-shaped checkpoint attestation that authorizes * `base`. When supplied it is integrity-checked (hash + genesis shape + * `subject.id === "czap/checkpoint:"`); a mismatch fails `checkpoint_invalid`. * - `verifyCheckpoint`: an OPTIONAL provenance verifier for the checkpoint — the * injectable capability that closes the one gap the structural checks cannot. */ export interface ChainValidationOptions { readonly base?: string; readonly checkpoint?: ReceiptEnvelope; /** * Provenance verifier for the checkpoint attestation (injected capability). * * The structural checks prove the checkpoint is WELL-FORMED (hash, `kind`, * `subject.type`, payload schema, genesis shape, `subject.id`, HLC-advance) but * NOT that it was minted by `DAG.checkpoint` over the real dropped set — a * compacted-tail validator does not hold the dropped set, so it cannot recompute * the summary `content_hash`. A forged genesis-shaped `kind:"checkpoint"` envelope * with the right subject id and an older timestamp therefore passes the structural * floor and could authorize an arbitrarily TRUNCATED tail. * * In a TRUSTED setting (single-actor self-compaction — you validate the checkpoint * YOU minted) the structural floor is sufficient and no verifier is needed. In an * ADVERSARIAL setting (an untrusted remote supplies the checkpoint) inject a * verifier that establishes provenance — e.g. checks a signature (a trusted * compactor's `Receipt.macEnvelope` over the attestation), or recomputes the * summary against a locally-held dropped set. It resolves `true` to accept, `false` * to reject (fails the chain `checkpoint_invalid`); any verification failure must * resolve `false`, not raise. Absent, only the structural floor applies. */ readonly verifyCheckpoint?: (checkpoint: ReceiptEnvelope) => Effect.Effect; } /** Sentinel `previous` value marking the root of a receipt chain. */ export const GENESIS: string = 'genesis'; /** * The schema id `DAG.checkpoint` stamps on its summary payload's `TypedRef` * (`schema_hash`). Compacted-tail validation binds authorization to this minted * shape — single source of truth, imported by `dag.ts` so the mint and the * verifier can never drift. */ export const CHECKPOINT_ATTESTATION_SCHEMA = 'czap/checkpoint-summary/v1'; /** * Compute the content hash of a receipt envelope. * * Normalizes the `previous` field (sorts array form), canonicalizes the * payload, and hashes with SHA-256 via TypedRef. * * @example * ```ts * import { Effect } from 'effect'; * * const hash = yield* Receipt.hashEnvelope(envelope); * // hash === envelope.hash (if envelope is valid) * ``` */ export const hashEnvelope = (envelope: ReceiptEnvelope): Effect.Effect => { const previousNormalized = Array.isArray(envelope.previous) ? [...(envelope.previous as readonly string[])].sort() : envelope.previous; const hashInput = TypedRefModule.canonicalize({ kind: envelope.kind, timestamp: envelope.timestamp, subject: envelope.subject, payload: envelope.payload, previous: previousNormalized, }); return TypedRefModule.hash(hashInput); }; /** * Create a new receipt envelope with an auto-computed content hash. * * @example * ```ts * const envelope = yield* Receipt.createEnvelope( * 'state-change', * { type: 'effect', id: 'actor-1' }, * { _tag: 'TypedRef', mediaType: 'application/json', data: { key: 'value' } }, * hlcTimestamp, * Receipt.GENESIS, * ); * // envelope.hash is the computed SHA-256 content address * ``` */ export const createEnvelope = ( kind: string, subject: ReceiptSubject, payload: TypedRef.Shape, timestamp: HLC, previousHash: string | readonly string[], ): Effect.Effect => Effect.gen(function* () { const previousNormalized = Array.isArray(previousHash) ? [...(previousHash as readonly string[])].sort() : previousHash; const partial = { kind, timestamp, subject, payload, previous: previousNormalized }; const h = yield* TypedRefModule.hash(TypedRefModule.canonicalize(partial)); return { kind, timestamp, subject, payload, hash: h, previous: previousNormalized }; }); /** * Build a linear chain of receipt envelopes from an array of entries. * * Each envelope's `previous` points to the prior envelope's hash, * starting from GENESIS. * * @example * ```ts * const chain = yield* Receipt.buildChain([ * { kind: 'init', subject: { type: 'effect', id: 'a' }, payload, timestamp: ts1 }, * { kind: 'update', subject: { type: 'effect', id: 'a' }, payload, timestamp: ts2 }, * ]); * // chain.length === 2 * // chain[1].previous === chain[0].hash * ``` */ export const buildChain = ( entries: ReadonlyArray<{ kind: string; subject: ReceiptSubject; payload: TypedRef.Shape; timestamp: HLC; }>, ): Effect.Effect => Effect.gen(function* () { const chain: ReceiptEnvelope[] = []; let previousHash = GENESIS; for (const entry of entries) { const envelope = yield* createEnvelope(entry.kind, entry.subject, entry.payload, entry.timestamp, previousHash); chain.push(envelope); previousHash = envelope.hash; } return chain; }); /** * Validate a receipt chain: genesis link, hash integrity, chain continuity, HLC ordering. * * The ergonomic everyday check: resolves only to `true` and signals every * violation through the `Error` channel with a human-readable message. * * @example * ```ts * const chain = yield* Receipt.buildChain(entries); * const valid = yield* Receipt.validateChain(chain); * // valid === true * ``` * * @see validateChainDetailed for typed `ChainValidationError` handling. */ export const validateChain = ( chain: ReadonlyArray, options?: ChainValidationOptions, ): Effect.Effect => validateChainDetailed(chain, options).pipe(Effect.mapError((error) => new Error(formatChainError(error, chain)))); /** * Render a {@link ChainValidationError} as the human-readable message * {@link validateChain} promises: what broke, on which envelope, with the * offending values and the literal next step. */ const formatChainError = (error: ChainValidationError, chain: ReadonlyArray): string => { switch (error.type) { case 'not_genesis': { const got = chain[0]?.previous; const gotText = Array.isArray(got) ? `[${got.join(', ')}]` : String(got); return ( `Envelope 0: previous="${gotText}" but a chain must start at previous="${GENESIS}". ` + 'If you sliced a longer chain, validate from index 0 of the original, or build the chain from Receipt.GENESIS.' ); } case 'hash_mismatch': return `Envelope ${error.index}: hash mismatch (expected "${error.stored}", computed "${error.computed}")`; case 'chain_break': return ( `Envelope ${error.index}: chain break — its previous="${error.actual}" does not equal ` + `envelope ${error.index - 1}'s hash "${error.expected}". The chain was reordered, truncated, ` + `or an envelope was tampered with; re-fetch the chain from its source or inspect envelope ${error.index}'s previous link.` ); case 'hlc_not_increasing': return `Envelope ${error.index}: HLC not monotonically increasing — its timestamp does not advance past envelope ${error.index - 1}'s. Re-issue the envelope with a fresh HLC.increment of the predecessor's timestamp.`; case 'checkpoint_invalid': return `Checkpoint attestation invalid: ${error.reason}. The compacted tail cannot be trusted against this checkpoint; re-fetch the checkpoint envelope from its source.`; } }; /** * Validate a receipt chain with detailed, structured error reporting. * * The typed taxonomy for programmatic handling: returns `true` on success * or fails with a `ChainValidationError` discriminated union * (not_genesis | hash_mismatch | chain_break | hlc_not_increasing). * * @example * ```ts * import { Effect } from 'effect'; * * const result = yield* Effect.either(Receipt.validateChainDetailed(chain)); * // result._tag === 'Right' on success * // result._tag === 'Left' with .left.type on failure * ``` * * @see validateChain for the simple Error-channel form. */ export const validateChainDetailed = ( chain: ReadonlyArray, options?: ChainValidationOptions, ): Effect.Effect => Effect.gen(function* () { const base = options?.base; const checkpoint = options?.checkpoint; // A `base` watermark widens the index-0 genesis predicate to accept a // compacted tail — but ONLY a verified checkpoint attestation authorizes that. // Accepting `base` alone would let any caller validate a TRUNCATED chain by // passing `base = tail[0].previous` with no proof the omitted prefix was ever // checkpointed. Require the checkpoint. (Codex finding #3.) if (base !== undefined && checkpoint === undefined) { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: 'a base watermark requires a checkpoint attestation to authorize compacted-tail validation', }); } // When a checkpoint is supplied, bind it to `base`: verify its content hash, // genesis shape, and that its subject commits exactly this watermark. This is // what authorizes the widened index-0 predicate below. if (checkpoint !== undefined) { if (base === undefined) { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: 'a checkpoint was supplied without a base watermark to bind it to', }); } const computedCheckpointHash = yield* hashEnvelope(checkpoint); if (computedCheckpointHash !== checkpoint.hash) { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: `checkpoint hash mismatch (expected "${checkpoint.hash}", computed "${computedCheckpointHash}")`, }); } // The attestation must actually be a checkpoint receipt — a different // genesis-shaped receipt that happens to carry the same subject id must NOT // authorize a compacted tail. if (checkpoint.kind !== 'checkpoint') { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: `checkpoint attestation has kind "${checkpoint.kind}" (expected "checkpoint")`, }); } // Bind to the MINTED checkpoint shape, not just kind + subject id: DAG.checkpoint // stamps subject.type "run" and a "czap/checkpoint-summary" payload. Otherwise a // forger could mint a genesis-shaped kind:"checkpoint" envelope with the right // subject id but an arbitrary payload + an older timestamp to authorize a // truncated tail. (Full cryptographic provenance would need a signature; this // rejects the structural forgeries.) if (checkpoint.subject.type !== 'run') { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: `checkpoint subject.type is "${checkpoint.subject.type}" (expected "run")`, }); } if (checkpoint.payload.schema_hash !== CHECKPOINT_ATTESTATION_SCHEMA) { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: `checkpoint payload schema is "${checkpoint.payload.schema_hash}" (expected "${CHECKPOINT_ATTESTATION_SCHEMA}")`, }); } if (!isGenesis(checkpoint)) { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: 'checkpoint is not genesis-shaped (previous must be GENESIS)', }); } const expectedSubjectId = `czap/checkpoint:${base}`; if (checkpoint.subject.id !== expectedSubjectId) { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: `checkpoint subject "${checkpoint.subject.id}" does not commit base watermark (expected "${expectedSubjectId}")`, }); } // Provenance gate (injected capability): the structural checks above prove the // checkpoint is well-formed but not that it attests to the REAL dropped set — // `validateChain` lacks that set, so it cannot recompute the summary // `content_hash`. An adversarial caller closes the residual forgery vector by // injecting `verifyCheckpoint` (e.g. a signature check); absent one, the // structural floor stands (sound for trusted self-compaction). See ADR-0026. if (options?.verifyCheckpoint !== undefined) { const attested = yield* options.verifyCheckpoint(checkpoint); if (!attested) { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: 'checkpoint failed the injected provenance verifier (not attested by a trusted compactor)', }); } } } // An empty chain is vacuously valid — but only AFTER any supplied checkpoint // is bound + verified, so `validateChainDetailed([], { base })` cannot pass // without the authorization a non-empty compacted tail requires. if (chain.length === 0) return true as const; const first = chain[0]!; const firstPrev = first.previous; // Index-0 genesis predicate, widened for a compacted tail: a retained tail's // first envelope points at the dropped watermark (`base`), not GENESIS. const firstIsGenesis = firstPrev === GENESIS || (Array.isArray(firstPrev) && (firstPrev as readonly string[]).includes(GENESIS)) || (base !== undefined && (firstPrev === base || (Array.isArray(firstPrev) && (firstPrev as readonly string[]).includes(base)))); if (!firstIsGenesis) { return yield* Effect.fail({ type: 'not_genesis' as const, index: 0 as const }); } // A compacted tail (its first envelope names `base`, not GENESIS) is a child of // the dropped watermark, so it MUST advance the HLC beyond the checkpoint — // whose timestamp is the HLC-max over the dropped prefix. Otherwise a // self-consistent but stale-HLC envelope naming `base` as `previous` would // validate where the full chain rejects it at the prefix boundary. const firstNamesBase = base !== undefined && (firstPrev === base || (Array.isArray(firstPrev) && (firstPrev as readonly string[]).includes(base))); if (firstNamesBase && checkpoint !== undefined && HLCOps.compare(first.timestamp, checkpoint.timestamp) <= 0) { return yield* Effect.fail({ type: 'checkpoint_invalid' as const, reason: 'compacted tail does not advance the HLC beyond the checkpoint watermark', }); } for (let i = 0; i < chain.length; i++) { const envelope = chain[i]!; const isMerge = Array.isArray(envelope.previous); const computedHash = yield* hashEnvelope(envelope); if (computedHash !== envelope.hash) { return yield* Effect.fail({ type: 'hash_mismatch' as const, index: i, computed: computedHash, stored: envelope.hash, }); } if (!isMerge && i > 0 && envelope.previous !== chain[i - 1]!.hash) { return yield* Effect.fail({ type: 'chain_break' as const, index: i, expected: chain[i - 1]!.hash, actual: envelope.previous as string, }); } if (!isMerge && i > 0 && HLCOps.compare(chain[i - 1]!.timestamp, envelope.timestamp) >= 0) { return yield* Effect.fail({ type: 'hlc_not_increasing' as const, index: i, }); } } return true as const; }); /** * Check whether a receipt envelope is a genesis (root) envelope. * * @example * ```ts * const chain = yield* Receipt.buildChain(entries); * Receipt.isGenesis(chain[0]); // true * Receipt.isGenesis(chain[1]); // false * ``` */ export const isGenesis = (receipt: ReceiptEnvelope): boolean => receipt.previous === GENESIS || (Array.isArray(receipt.previous) && (receipt.previous as readonly string[]).includes(GENESIS)); /** * Get the last (most recent) envelope in a chain. * * @example * ```ts * const latest = Receipt.head(chain); * // latest === chain[chain.length - 1] * ``` */ export const head = (chain: ReadonlyArray): ReceiptEnvelope | undefined => chain.length > 0 ? chain[chain.length - 1] : undefined; /** * Get the first (genesis) envelope in a chain. * * @example * ```ts * const first = Receipt.tail(chain); * // first === chain[0] * ``` */ export const tail = (chain: ReadonlyArray): ReceiptEnvelope | undefined => chain.length > 0 ? chain[0] : undefined; /** * Append a new entry to an existing chain, auto-linking to the previous hash. * * Optionally accepts explicit previous hashes for merge envelopes. * * @example * ```ts * const chain = yield* Receipt.buildChain([entry1]); * const extended = yield* Receipt.append(chain, { * kind: 'update', subject: { type: 'effect', id: 'a' }, payload, timestamp: ts2, * }); * // extended.length === 2 * ``` */ export const append = ( chain: ReadonlyArray, entry: { kind: string; subject: ReceiptSubject; payload: TypedRef.Shape; timestamp: HLC }, previousHashes?: readonly string[], ): Effect.Effect => Effect.gen(function* () { const previousHash: string | readonly string[] = previousHashes ? previousHashes : chain.length > 0 ? chain[chain.length - 1]!.hash : GENESIS; const envelope = yield* createEnvelope(entry.kind, entry.subject, entry.payload, entry.timestamp, previousHash); return [...chain, envelope]; }); /** * Find an envelope in a chain by its content hash. * * @example * ```ts * const found = Receipt.findByHash(chain, targetHash); * // found?.hash === targetHash * ``` */ export const findByHash = (chain: ReadonlyArray, hash: string): ReceiptEnvelope | undefined => chain.find((e) => e.hash === hash); /** * Find all envelopes in a chain matching a given kind. * * @example * ```ts * const updates = Receipt.findByKind(chain, 'update'); * // updates contains all envelopes with kind === 'update' * ``` */ export const findByKind = (chain: ReadonlyArray, kind: string): ReceiptEnvelope[] => chain.filter((e) => e.kind === kind); /** * Generate an HMAC-SHA-256 key for signing receipt envelopes. * * @example * ```ts * const key = yield* Receipt.generateMACKey(); * const signed = yield* Receipt.macEnvelope(envelope, key); * // signed.signature is a hex string * ``` */ export const generateMACKey = (): Effect.Effect => Effect.tryPromise({ try: () => crypto.subtle.generateKey({ name: 'HMAC', hash: { name: 'SHA-256' } }, true, ['sign', 'verify']), catch: (error) => new Error(`Failed to generate MAC key: ${error}`), }); /** * Sign a receipt envelope with an HMAC key, adding a `signature` field. * * @example * ```ts * const key = yield* Receipt.generateMACKey(); * const signed = yield* Receipt.macEnvelope(envelope, key); * // signed.signature !== undefined * ``` */ export const macEnvelope = (envelope: ReceiptEnvelope, key: CryptoKey): Effect.Effect => Effect.gen(function* () { const data = new TextEncoder().encode(envelope.hash); const signatureBuffer = yield* Effect.tryPromise({ try: () => crypto.subtle.sign('HMAC', key, data), catch: (error) => new Error(`Failed to MAC envelope: ${error}`), }); const signatureArray = Array.from(new Uint8Array(signatureBuffer)); const signature = signatureArray.map((b) => b.toString(16).padStart(2, '0')).join(''); return { ...envelope, signature }; }); /** * Verify an envelope's HMAC signature against a key. * * Returns false if the envelope has no signature. * * @example * ```ts * const valid = yield* Receipt.verifyMAC(signedEnvelope, key); * // valid === true if signature matches * ``` */ export const verifyMAC = (envelope: ReceiptEnvelope, key: CryptoKey): Effect.Effect => Effect.gen(function* () { if (!envelope.signature) return false; const signatureHex = envelope.signature; if (!/^[0-9a-fA-F]+$/.test(signatureHex) || signatureHex.length % 2 !== 0) { return yield* Effect.fail(ParseError('signature-hex', 'expected even-length hex string', { code: 'malformed' })); } const signatureArray = new Uint8Array(signatureHex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); const data = new TextEncoder().encode(envelope.hash); const valid = yield* Effect.tryPromise({ try: () => crypto.subtle.verify('HMAC', key, signatureArray, data), catch: (error) => new Error(`Failed to verify signature: ${error}`), }); return valid; }); /** * Receipt namespace -- chain validation and envelope construction. * * Build, validate, append, query, and sign linear receipt chains. * Each envelope is content-addressed and linked to its predecessor. * Supports HMAC signing/verification for tamper detection. * * @example * ```ts * import { Effect } from 'effect'; * import { Receipt, HLC } from '@czap/core'; * * const program = Effect.gen(function* () { * const ts = HLC.increment(HLC.create('node-1'), Date.now()); * const chain = yield* Receipt.buildChain([ * { kind: 'init', subject: { type: 'effect', id: 'a' }, payload, timestamp: ts }, * ]); * const valid = yield* Receipt.validateChain(chain); * const latest = Receipt.head(chain); * }); * ``` */ export const Receipt = { GENESIS, createEnvelope, buildChain, validateChain, validateChainDetailed, hashEnvelope, isGenesis, head, tail, append, findByHash, findByKind, generateMACKey, macEnvelope, verifyMAC, }; export declare namespace Receipt { /** Alias for {@link ReceiptSubject}. */ export type Subject = ReceiptSubject; /** Alias for {@link ReceiptEnvelope}. */ export type Envelope = ReceiptEnvelope; /** Alias for {@link ChainValidationError}. */ export type ChainError = ChainValidationError; }