// Copyright 2024-2026 Tymofii Pidlisnyi. Apache-2.0 license. See LICENSE.
// ══════════════════════════════════════════════════════════════════
// Cycles external-evidence resolution: claimed vs resolved (W2-A2)
// ══════════════════════════════════════════════════════════════════
// A cycles receipt carries a CyclesEvidenceRef: a `cycles_evidence_url`
// plus a CLAIMED content hash `cycles_evidence_id_sha256`. The receipt
// signature covers that claimed hash, so a signature check shows only
// that SOMEONE signed a pointer to a hash. It does NOT show that an
// envelope exists at the URL, nor that the envelope there hashes to the
// claimed value.
//
// This module closes the explicit join-integrity gap (the TODO at
// cycles/index.ts) WITHOUT making the SDK perform network egress. The
// caller supplies an EvidenceResolver (the M3 caller-supplied-resolver
// pattern: an interface that fetches the envelope behind the caller's
// own transport). The resolver returns the fetched envelope bytes; this
// module recomputes the Cycles content-hash recipe (JCS-canonical
// envelope with `evidence_id` AND `signature` emptied, sha256 hex) and
// compares it byte-for-byte against the claimed hash on the receipt.
//
// SECURITY POSTURE (mirrors M3 key-resolution): default fail-closed.
// "Could not fetch the envelope" is never "envelope matched". A
// degraded/unreachable resolution MUST NOT read as resolved=true.
//
// SCOPE: this is an SDK slot/format/hook. It defines the resolver
// INTERFACE, the recompute-and-match operation, and a descriptor INPUT
// the verifier emits. It ships no hosted resolution service, no
// registry, no cross-tenant aggregation, and reaches no global `fetch`
// directly: the egress lives entirely behind the caller's resolver.
// ══════════════════════════════════════════════════════════════════
import { canonicalizeJCS } from '../../../core/canonical-jcs.js';
import { sha256Hex } from '../canonicalize.js';
// ── Content-hash recipe ───────────────────────────────────────────
/**
 * Recompute the Cycles content-addressed id of a fetched envelope:
 * JCS-canonical bytes of the envelope with both `evidence_id` and
 * `signature` set to empty string, then sha256 hex. Reproduces the
 * recipe documented on CyclesEvidenceRef.cycles_evidence_id_sha256 and
 * matched by the Cycles server, so the recomputed value is comparable
 * byte-for-byte against the claimed hash.
 */
export function recomputeEvidenceContentHash(envelope) {
    const canonical = canonicalizeJCS({
        ...envelope,
        evidence_id: '',
        signature: '',
    });
    return sha256Hex(canonical);
}
// ── Scope-of-claim assertions (dogfood the descriptor) ────────────
function matchedScope() {
    return {
        asserts: 'aps:cycles.evidence.resolve:matched. An envelope was fetched at the receipt cycles_evidence_url and its recomputed content hash equals the claimed cycles_evidence_id_sha256.',
        does_not_assert: [
            'the envelope content is itself truthful about the underlying rail event',
            'the envelope endpoint asserts the same bytes at any later time',
            'the budget reservation settled',
        ],
        capture_mode: 'gateway_observed',
        completeness: 'complete',
        self_attested: false,
    };
}
function claimedOnlyScope() {
    return {
        asserts: 'aps:cycles.evidence.resolve:claimed_only. The receipt signature covers a pointer to a content hash; no envelope was fetched and matched, so only that someone signed a reference to the hash is shown.',
        does_not_assert: [
            'an envelope exists at the cycles_evidence_url',
            'the envelope at the URL hashes to the claimed cycles_evidence_id_sha256',
            'the claimed hash corresponds to any real rail event',
        ],
        capture_mode: 'unknown',
        completeness: 'best_effort',
        self_attested: false,
    };
}
// ── resolveEvidenceRef: the claimed-vs-resolved operation ─────────
/**
 * Resolve a CyclesEvidenceRef against a caller-supplied resolver and
 * report the claimed-vs-resolved verdict.
 *
 * Records failure, never throws. A missing resolver yields a
 * 'no_resolver' result (claimed-not-resolved), NOT an error. A resolver
 * that reports unreachable yields 'unreachable' (degraded under
 * fail-open, still non-matching). Only a fetched envelope whose
 * recomputed hash equals the claimed hash yields resolved=true.
 *
 * @param ref       the receipt's cycles_evidence join shape
 * @param resolver  caller-supplied envelope resolver, or undefined
 * @param config    failure policy (default fail-closed)
 */
export async function resolveEvidenceRef(ref, resolver, config = {}) {
    const claimed = ref.cycles_evidence_id_sha256;
    const failurePolicy = config.failurePolicy ?? 'closed';
    // No resolver supplied: the claimed hash was never tested. This is the
    // fail-closed default for "the caller did not opt into resolution".
    if (!resolver || !resolver.canResolve(ref.cycles_evidence_url)) {
        return {
            claimed_sha256: claimed,
            resolved: false,
            status: 'no_resolver',
            reason: resolver
                ? 'resolver declined this evidence url'
                : 'no evidence resolver supplied; claimed hash not tested',
            scope_of_claim: claimedOnlyScope(),
        };
    }
    let fetched;
    try {
        fetched = await resolver.resolve(ref.cycles_evidence_url);
    }
    catch (err) {
        // A resolver SHOULD NOT reject, but if it does we record it as
        // unreachable rather than letting it throw out of the verifier.
        return unreachableResult(claimed, failurePolicy, `resolver threw: ${safeMsg(err)}`);
    }
    if (!fetched.ok || !fetched.envelope) {
        const status = fetched.status ?? 'unreachable';
        if (status === 'unreachable') {
            return unreachableResult(claimed, failurePolicy, fetched.reason ?? 'envelope unreachable');
        }
        // not_found / malformed always fail closed (loaded-but-wrong is not
        // a transient condition).
        return {
            claimed_sha256: claimed,
            resolved: false,
            status,
            reason: fetched.reason ?? `envelope ${status}`,
            scope_of_claim: claimedOnlyScope(),
        };
    }
    // Envelope fetched: recompute and compare.
    let recomputed;
    try {
        recomputed = recomputeEvidenceContentHash(fetched.envelope);
    }
    catch (err) {
        return {
            claimed_sha256: claimed,
            resolved: false,
            status: 'malformed',
            reason: `envelope canonicalization failed: ${safeMsg(err)}`,
            scope_of_claim: claimedOnlyScope(),
        };
    }
    if (recomputed === claimed) {
        return {
            claimed_sha256: claimed,
            resolved: true,
            status: 'matched',
            resolved_sha256: recomputed,
            scope_of_claim: matchedScope(),
        };
    }
    return {
        claimed_sha256: claimed,
        resolved: false,
        status: 'hash_mismatch',
        resolved_sha256: recomputed,
        reason: 'fetched envelope hash does not equal claimed cycles_evidence_id_sha256',
        scope_of_claim: claimedOnlyScope(),
    };
}
// ── helpers ───────────────────────────────────────────────────────
function unreachableResult(claimed, failurePolicy, reason) {
    // fail-open relaxes ONLY unreachable into a degraded, still
    // non-matching outcome. fail-closed (default) is identical except for
    // the degraded flag; neither ever sets resolved=true.
    return {
        claimed_sha256: claimed,
        resolved: false,
        status: 'unreachable',
        degraded: failurePolicy === 'open',
        reason: failurePolicy === 'open'
            ? `fail-open degraded: ${reason}`
            : `fail-closed: ${reason}`,
        scope_of_claim: claimedOnlyScope(),
    };
}
function safeMsg(err) {
    return err instanceof Error ? err.message : String(err);
}
/**
 * Project a resolution result into the W2-A1 descriptor input. Pure,
 * mechanical, total. `counterparty_resolved` iff the envelope was
 * fetched and matched; every other status is `signature_only` (the
 * receipt signature still stands on its own; it simply was not
 * corroborated by a fetched envelope).
 */
export function toEvidenceDescriptorInput(result) {
    const input = {
        claimed_sha256: result.claimed_sha256,
        observation_basis: result.resolved ? 'counterparty_resolved' : 'signature_only',
        envelope_matched: result.resolved,
        resolution_status: result.status,
    };
    if (result.resolved_sha256 !== undefined) {
        input.resolved_sha256 = result.resolved_sha256;
    }
    if (result.degraded !== undefined) {
        input.degraded = result.degraded;
    }
    return input;
}
//# sourceMappingURL=evidence-resolution.js.map