/**
 * RagCitationEnforcer -- Post-retrieval citation chain-of-custody enforcement.
 *
 * When an agent produces output that cites sources, every cited (sourceId,
 * chunkRef) pair MUST appear in the retrieval pool for the current cycle.
 * A citation that references a source never retrieved in this cycle is a
 * phantom citation -- the canonical OWASP LLM A05 (Cascading Hallucination)
 * attack vector. This class closes that gap.
 *
 * Relationship to PR #18 RagSourceAllowlist:
 *   RagSourceAllowlist (pre-retrieval):  which sources may be queried.
 *   RagCitationEnforcer (post-retrieval): which citations are provably from
 *     the retrieval pool. Together they form chain-of-custody: only allowlisted
 *     sources can be retrieved, and only retrieved chunks can be cited.
 *
 * Citation schema:
 *   Citation = {
 *     sourceId:   string   -- matches a source registered in the retrieval pool
 *     sourceKind: RagSourceKind
 *     chunkRef:   string   -- identifies the specific chunk (non-empty required)
 *     quotedSpan: string   -- optional verbatim excerpt for provability
 *   }
 *
 * Retrieval-pool tracking (in-memory, per-cycle):
 *   When an agent retrieves from a source (post allowlist gate), the caller
 *   invokes recordRetrieval(). The pool for that cycle is the union of all
 *   recorded retrievals. Pool is released via clearCycle() at cycle end.
 *
 * Strict-mode packs -- same five packs as RagSourceAllowlist PR #18:
 *   hipaa, gdpr, pci-dss, hmda, dora
 *   For these packs, output MUST include at least one Citation or
 *   CitationRequiredError is thrown (requiresCitations: true in pack metadata).
 *
 * Quoted-span verification (v0):
 *   If quotedSpan is provided AND the pool entry includes chunkText, the span
 *   is verified as a substring. If chunkText is absent from the pool entry,
 *   a CITATION_QUOTED_SPAN_MISMATCH warning is emitted and the output passes
 *   (best-effort). v1 plan: require chunkText at recordRetrieval time for all
 *   strict-mode packs.
 *
 * Audit events emitted:
 *   CITATION_OUTSIDE_POOL           severity ERROR  -- phantom citation attack
 *   CITATION_REQUIRED_MISSING       severity ERROR  -- strict pack, no citations
 *   CITATION_MISSING_CHUNK_REF      severity ERROR  -- citation lacks chunkRef
 *   CITATION_QUOTED_SPAN_MISMATCH   severity WARNING -- span absent from chunk
 *   CITATION_VALIDATED              severity INFO   -- all citations ok
 *
 * Output text is NEVER written to the audit chain. SHA-256 hash only.
 * Pack-binding is mandatory on every call (UnboundDataError if empty).
 *
 * Pen-test gates:
 *   PT-RAG-CITATION-1: phantom-citation attack -- agent cites a sourceId on
 *     the allowlist but never retrieved this cycle. Pool check fires.
 *   PT-RAG-CITATION-2: chunk-id confusion -- pool has "c123", agent cites
 *     "c123 " (trailing whitespace). Exact match required, confusable rejected.
 *   PT-RAG-CITATION-3: cross-cycle pool leakage -- cycle X retrieves chunk cA.
 *     Agent in cycle Y cites cA. Cycle Y pool lacks cA, so citation rejected.
 *
 * BUILD_PLAN reference: line 247 "Citation requirement enforcement (OWASP A05
 * cascading hallucination)".
 *
 * @connexum/ai-governance
 */
import * as crypto from 'node:crypto';
// ---------------------------------------------------------------------------
// Strict-mode pack list (mirrors RagSourceAllowlist.STRICT_MODE_PACKS)
// ---------------------------------------------------------------------------
/**
 * Packs that REQUIRE at least one citation on every agent output.
 * These are the same packs that force fail-closed on retrieval source
 * allowlisting (PR #18). Together they form the strict RAG governance posture.
 *
 * hipaa   -- HIPAA §164.312(a)(1): PHI claim provenance must be traceable to
 *            verified clinical-record sources, not unattributed inference.
 * gdpr    -- GDPR Art. 5(1)(a) lawfulness, Art. 5(1)(d) accuracy: agents
 *            making claims about data subjects must cite the record used.
 * pci-dss -- PCI DSS Req 12.3 targeted risk analysis: risk claims must cite
 *            the specific assessment or evidence document.
 * hmda    -- HMDA Reg C §1003: GMI data usage must be traceable to the
 *            verified collection form, not synthesised from model weights.
 * dora    -- DORA Art. 9 ICT risk: incident and risk claims must be grounded
 *            in retrieved ICT register or incident data.
 */
export const CITATION_STRICT_MODE_PACKS = new Set([
    'hipaa',
    'gdpr',
    'pci-dss',
    'hmda',
    'dora',
]);
// ---------------------------------------------------------------------------
// Error classes
// ---------------------------------------------------------------------------
/**
 * Thrown when a citation references a (sourceId, chunkRef) not present in
 * the agent's retrieval pool for this cycle.
 * OWASP LLM A05 (Cascading Hallucination) prevention gate.
 */
export class CitationOutsidePoolError extends Error {
    orgId;
    agentId;
    cycleId;
    citationIndex;
    sourceId;
    chunkRef;
    constructor(opts) {
        super(`RAG citation blocked: agent '${opts.agentId}' (org '${opts.orgId}') cycle '${opts.cycleId}' ` +
            `citation[${opts.citationIndex}] references sourceId '${opts.sourceId}' chunkRef '${opts.chunkRef}' ` +
            `which is not in the retrieval pool for this cycle. ` +
            `This is the OWASP LLM A05 phantom-citation pattern. ` +
            `The agent may only cite sources it actually retrieved in the current cycle.`);
        this.name = 'CitationOutsidePoolError';
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
        this.cycleId = opts.cycleId;
        this.citationIndex = opts.citationIndex;
        this.sourceId = opts.sourceId;
        this.chunkRef = opts.chunkRef;
    }
}
/**
 * Thrown when an agent bound to a strict-mode pack produces output with
 * no citations. Strict packs require at least one citation per output.
 */
export class CitationRequiredError extends Error {
    orgId;
    agentId;
    packIds;
    constructor(opts) {
        super(`RAG citation required: agent '${opts.agentId}' (org '${opts.orgId}') is bound to ` +
            `strict-mode pack(s) [${opts.packIds.join(', ')}] which mandate at least one citation ` +
            `per output (requiresCitations: true). The output provided zero citations. ` +
            `Include at least one Citation object with a valid sourceId and chunkRef.`);
        this.name = 'CitationRequiredError';
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
        this.packIds = opts.packIds;
    }
}
/**
 * Thrown when a citation has an empty or whitespace-only chunkRef.
 * Vague citations (no chunk reference) are rejected: they provide no
 * verifiable provenance and can mask hallucinated source claims.
 */
export class CitationMissingChunkRefError extends Error {
    orgId;
    agentId;
    citationIndex;
    sourceId;
    constructor(opts) {
        super(`RAG citation rejected: agent '${opts.agentId}' (org '${opts.orgId}') ` +
            `citation[${opts.citationIndex}] for sourceId '${opts.sourceId}' has an empty chunkRef. ` +
            `Citations must reference a specific chunk (non-empty, non-whitespace chunkRef). ` +
            `Vague source citations without a chunk identifier are rejected.`);
        this.name = 'CitationMissingChunkRefError';
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
        this.citationIndex = opts.citationIndex;
        this.sourceId = opts.sourceId;
    }
}
/**
 * Thrown when configure() or recordRetrieval() receives an empty packBinding.
 * Per feedback_pack_binding_primacy.md: every record must carry packBinding.
 */
export class UnboundCitationDataError extends Error {
    context;
    constructor(context) {
        super(`RagCitationEnforcer: packBinding must not be empty. Context: ${context}. ` +
            `Every citation enforcement call must carry at least one pack binding for ` +
            `retention policy derivation and audit traceability.`);
        this.name = 'UnboundCitationDataError';
        this.context = context;
    }
}
// ---------------------------------------------------------------------------
// Internal key helpers
// ---------------------------------------------------------------------------
function agentKey(orgId, agentId) {
    return `${orgId}::${agentId}`;
}
function poolKey(orgId, agentId, cycleId) {
    return `${orgId}::${agentId}::${cycleId}`;
}
// ---------------------------------------------------------------------------
// RagCitationEnforcer
// ---------------------------------------------------------------------------
/**
 * Post-retrieval citation chain-of-custody enforcer.
 *
 * Usage (called by the integration layer after PR #18 allowlist gate):
 *
 *   const enforcer = new RagCitationEnforcer(auditSink);
 *
 *   // 1. After allowlist passes and retrieval completes:
 *   enforcer.recordRetrieval('org-a', 'agent-1', 'cycle-42', {
 *     sourceId: 'vs_acme_pubmed',
 *     sourceKind: 'vector-store',
 *     chunkRefs: ['c001', 'c002'],
 *     chunkTexts: { c001: 'Full text of chunk c001...', c002: '...' },
 *   }, ['hipaa']);
 *
 *   // 2. When agent emits output:
 *   enforcer.validateOutput('org-a', 'agent-1', 'cycle-42', {
 *     text: 'The patient record shows...',
 *     citations: [{ sourceId: 'vs_acme_pubmed', sourceKind: 'vector-store',
 *                   chunkRef: 'c001', quotedSpan: 'patient record' }],
 *   }, ['hipaa']);
 *
 *   // Strict-pack gate (requiresCitations check):
 *   enforcer.requireCitations('org-a', 'agent-1', output, ['hipaa']);
 *
 *   // 3. At cycle end:
 *   enforcer.clearCycle('org-a', 'agent-1', 'cycle-42');
 */
export class RagCitationEnforcer {
    /**
     * Retrieval pool: maps poolKey(org, agent, cycle) -> list of pool entries.
     * Each call to recordRetrieval() appends to this list.
     */
    pool = new Map();
    /**
     * Pack binding index: maps agentKey(org, agent) -> last-known packBinding.
     * Used by requireCitations() when no packBinding is provided on output.
     */
    agentPackBindings = new Map();
    /** SHA-256 of the last emitted audit event (chain linkage). */
    prevSha = '0'.repeat(64);
    auditSink;
    constructor(auditSink) {
        this.auditSink = auditSink ?? (() => undefined);
    }
    // ---------------------------------------------------------------------------
    // recordRetrieval()
    // ---------------------------------------------------------------------------
    /**
     * Register a retrieval event in the pool for this cycle.
     *
     * Must be called AFTER RagSourceAllowlist.guardPreRetrieval() passes and
     * the actual retrieval completes. The pool records which chunks from which
     * sources were actually fetched in this cycle.
     *
     * @param orgId        Operator/tenant identifier.
     * @param agentId      Agent identifier.
     * @param cycleId      Cycle identifier (isolates pool across cycles).
     * @param source       Retrieval source descriptor.
     * @param source.sourceId   Source identifier (vector-store ID, URL, etc.).
     * @param source.sourceKind Kind of source.
     * @param source.chunkRefs  Chunk references retrieved.
     * @param source.chunkTexts Optional chunk text for quoted-span verification.
     * @param packBinding  Pack IDs governing this retrieval. Must be non-empty.
     * @throws UnboundCitationDataError if packBinding is empty.
     */
    recordRetrieval(orgId, agentId, cycleId, source, packBinding) {
        if (!packBinding || packBinding.length === 0) {
            throw new UnboundCitationDataError(`recordRetrieval orgId=${orgId} agentId=${agentId} cycleId=${cycleId} sourceId=${source.sourceId}`);
        }
        const key = poolKey(orgId, agentId, cycleId);
        const entries = this.pool.get(key) ?? [];
        entries.push({
            orgId,
            agentId,
            cycleId,
            sourceId: source.sourceId,
            sourceKind: source.sourceKind,
            chunkRefs: source.chunkRefs,
            chunkTexts: source.chunkTexts,
            packBinding,
            retrievedAt: new Date().toISOString(),
        });
        this.pool.set(key, entries);
        // Update agent pack binding index
        this.agentPackBindings.set(agentKey(orgId, agentId), packBinding);
    }
    // ---------------------------------------------------------------------------
    // validateOutput()
    // ---------------------------------------------------------------------------
    /**
     * Validate that every citation in the output references a (sourceId, chunkRef)
     * that was actually retrieved in this cycle's pool.
     *
     * Checks (in order):
     *   1. Each citation must have a non-empty chunkRef. Throws CitationMissingChunkRefError.
     *   2. Each (sourceId, chunkRef) must appear in the cycle pool. Throws CitationOutsidePoolError.
     *   3. If quotedSpan is provided, verify it against chunkText (best-effort). Emits WARNING.
     *
     * Does NOT check requiresCitations (use requireCitations() for that gate).
     *
     * @param orgId       Operator/tenant identifier.
     * @param agentId     Agent identifier.
     * @param cycleId     Cycle identifier.
     * @param output      Agent output with text and citations array.
     * @param packBinding Pack IDs governing this output. Must be non-empty.
     * @throws UnboundCitationDataError if packBinding is empty.
     * @throws CitationMissingChunkRefError if a citation lacks chunkRef.
     * @throws CitationOutsidePoolError if a citation is not in the pool.
     */
    validateOutput(orgId, agentId, cycleId, output, packBinding) {
        if (!packBinding || packBinding.length === 0) {
            throw new UnboundCitationDataError(`validateOutput orgId=${orgId} agentId=${agentId} cycleId=${cycleId}`);
        }
        // Update agent pack binding index
        this.agentPackBindings.set(agentKey(orgId, agentId), packBinding);
        const outputHash = this.sha256(output.text);
        const poolEntries = this.pool.get(poolKey(orgId, agentId, cycleId)) ?? [];
        for (let i = 0; i < output.citations.length; i++) {
            const citation = output.citations[i];
            // Check 1: chunkRef must be non-empty and non-whitespace
            if (!citation.chunkRef || citation.chunkRef.trim() === '') {
                const event = {
                    type: 'CITATION_MISSING_CHUNK_REF',
                    severity: 'ERROR',
                    orgId,
                    agentId,
                    cycleId,
                    citationIndex: i,
                    outputHash,
                    sourceId: citation.sourceId,
                    prevSha: this.prevSha,
                    timestamp: new Date().toISOString(),
                };
                this.emit(event);
                throw new CitationMissingChunkRefError({
                    orgId,
                    agentId,
                    citationIndex: i,
                    sourceId: citation.sourceId,
                });
            }
            // Check 2: (sourceId, chunkRef) must be in the pool
            // chunkRef matching is exact and whitespace-significant (PT-RAG-CITATION-2)
            const poolMatch = this.findInPool(poolEntries, citation.sourceId, citation.chunkRef);
            if (!poolMatch) {
                const event = {
                    type: 'CITATION_OUTSIDE_POOL',
                    severity: 'ERROR',
                    orgId,
                    agentId,
                    cycleId,
                    citationIndex: i,
                    outputHash,
                    sourceId: citation.sourceId,
                    chunkRef: citation.chunkRef,
                    prevSha: this.prevSha,
                    timestamp: new Date().toISOString(),
                };
                this.emit(event);
                throw new CitationOutsidePoolError({
                    orgId,
                    agentId,
                    cycleId,
                    citationIndex: i,
                    sourceId: citation.sourceId,
                    chunkRef: citation.chunkRef,
                });
            }
            // Check 3: quoted-span verification (best-effort v0)
            if (citation.quotedSpan !== undefined) {
                this.verifyQuotedSpan(orgId, agentId, cycleId, i, citation.sourceId, citation.chunkRef, citation.quotedSpan, poolMatch);
            }
        }
        // All citations validated
        const successEvent = {
            type: 'CITATION_VALIDATED',
            severity: 'INFO',
            orgId,
            agentId,
            cycleId,
            citationCount: output.citations.length,
            outputHash,
            timestamp: new Date().toISOString(),
        };
        this.auditSink(successEvent);
    }
    // ---------------------------------------------------------------------------
    // requireCitations()
    // ---------------------------------------------------------------------------
    /**
     * Strict-pack gate: if any bound pack has requiresCitations: true,
     * the output MUST contain at least one citation.
     *
     * This gate is SEPARATE from validateOutput(). Call requireCitations()
     * BEFORE validateOutput() so an empty citation array fails fast.
     *
     * The five strict packs (hipaa, gdpr, pci-dss, hmda, dora) are hardcoded
     * in CITATION_STRICT_MODE_PACKS. The pack field requiresCitations: true
     * on the CompliancePack interface mirrors this at the metadata layer.
     *
     * @param orgId       Operator/tenant identifier.
     * @param agentId     Agent identifier.
     * @param output      Agent output. Only output.citations.length is checked.
     * @param packBinding Pack IDs governing this output. Must be non-empty.
     * @throws UnboundCitationDataError if packBinding is empty.
     * @throws CitationRequiredError if strict pack bound and zero citations.
     */
    requireCitations(orgId, agentId, output, packBinding) {
        if (!packBinding || packBinding.length === 0) {
            throw new UnboundCitationDataError(`requireCitations orgId=${orgId} agentId=${agentId}`);
        }
        const strictActive = packBinding.filter((p) => CITATION_STRICT_MODE_PACKS.has(p));
        if (strictActive.length > 0 && output.citations.length === 0) {
            const outputHash = this.sha256(output.text);
            const event = {
                type: 'CITATION_REQUIRED_MISSING',
                severity: 'ERROR',
                orgId,
                agentId,
                cycleId: '__require-check__',
                outputHash,
                strictPacks: strictActive,
                prevSha: this.prevSha,
                timestamp: new Date().toISOString(),
            };
            this.emit(event);
            throw new CitationRequiredError({ orgId, agentId, packIds: strictActive });
        }
    }
    // ---------------------------------------------------------------------------
    // clearCycle()
    // ---------------------------------------------------------------------------
    /**
     * Release the retrieval pool for this cycle. Call at cycle end to prevent
     * cross-cycle pool leakage (PT-RAG-CITATION-3).
     *
     * After clearCycle(), any citation referencing chunks from this cycle will
     * be rejected by validateOutput() since the pool is empty.
     */
    clearCycle(orgId, agentId, cycleId) {
        const key = poolKey(orgId, agentId, cycleId);
        this.pool.delete(key);
    }
    // ---------------------------------------------------------------------------
    // getPool() -- read-only view for testing / inspection
    // ---------------------------------------------------------------------------
    /**
     * Returns a read-only snapshot of the retrieval pool for the given cycle.
     * Returns an empty array if no retrievals have been recorded.
     */
    getPool(orgId, agentId, cycleId) {
        return this.pool.get(poolKey(orgId, agentId, cycleId)) ?? [];
    }
    // ---------------------------------------------------------------------------
    // Private helpers
    // ---------------------------------------------------------------------------
    /**
     * Find a pool entry that contains the given (sourceId, chunkRef).
     * chunkRef matching is exact and whitespace-significant.
     * Returns the pool entry, or null if not found.
     */
    findInPool(entries, sourceId, chunkRef) {
        for (const entry of entries) {
            if (entry.sourceId !== sourceId)
                continue;
            // Exact match: whitespace-significant (PT-RAG-CITATION-2)
            if (entry.chunkRefs.includes(chunkRef)) {
                return entry;
            }
        }
        return null;
    }
    /**
     * Quoted-span verification (v0 best-effort).
     *
     * If the pool entry has chunkText for the cited chunkRef, verify that
     * quotedSpan is a substring. If chunkText is absent, emit a WARNING
     * and allow the citation (v0 limitation).
     */
    verifyQuotedSpan(orgId, agentId, cycleId, citationIndex, sourceId, chunkRef, quotedSpan, poolEntry) {
        // If the pool entry has no chunkTexts map at all, or no text for this chunk:
        if (!poolEntry.chunkTexts || !(chunkRef in poolEntry.chunkTexts)) {
            const event = {
                type: 'CITATION_QUOTED_SPAN_MISMATCH',
                severity: 'WARNING',
                orgId,
                agentId,
                cycleId,
                citationIndex,
                sourceId,
                chunkRef,
                reason: 'no-chunk-text',
                timestamp: new Date().toISOString(),
            };
            this.auditSink(event);
            // v0: pass (best-effort) -- no chunkText available for verification
            return;
        }
        const chunkText = poolEntry.chunkTexts[chunkRef];
        if (!chunkText.includes(quotedSpan)) {
            const event = {
                type: 'CITATION_QUOTED_SPAN_MISMATCH',
                severity: 'WARNING',
                orgId,
                agentId,
                cycleId,
                citationIndex,
                sourceId,
                chunkRef,
                reason: 'span-not-found',
                timestamp: new Date().toISOString(),
            };
            this.auditSink(event);
            // WARNING only -- does not throw. Output passes.
        }
        // If span is found: silently pass (INFO is covered by CITATION_VALIDATED)
    }
    /**
     * Emit an audit event and advance the prev-SHA chain (for ERROR events).
     * The chain links ERROR events sequentially. INFO/WARNING events do not
     * advance the chain (they are not tamper-evidence targets).
     */
    emit(event) {
        this.auditSink(event);
        this.prevSha = crypto
            .createHash('sha256')
            .update(JSON.stringify(event), 'utf8')
            .digest('hex');
    }
    /**
     * Compute SHA-256 of a string.
     * Used to hash output text for audit logs so raw content never enters
     * the audit chain.
     */
    sha256(input) {
        return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
    }
}
//# sourceMappingURL=rag-citation-enforcement.js.map