/**
 * SkipApiEmptyQueueEnforcer -- Pre-call gate that prevents LLM calls when
 * the agent's work queue is empty.
 *
 * Problem: polling-style agents may issue an LLM call on every tick even
 * when no work is pending, burning tokens for "nothing to do" responses.
 * This gate asks the operator-supplied queue adapter: "is there work?"
 * If the answer is no, it throws EmptyQueueSkipApiError and the caller
 * skips the LLM call entirely.
 *
 * Operator opt-in:
 *   Default: NOT enabled. Operators must call configure() for each agent.
 *   Agents without a registered adapter pass through guardPreCall() as a
 *   no-op. This is intentional -- some agents legitimately poll for events
 *   that are not queue-shaped (cron triggers, health checks, etc.).
 *
 * Position in the hook chain:
 *   VERY EARLY -- before cycle-token-budget, rate-limit, and RAG gates.
 *   If the queue is empty, skip ALL downstream gates and avoid every
 *   compute-heavy check.
 *
 * Fail-closed on adapter error:
 *   If the adapter throws, guardPreCall() rethrows. It does NOT default to
 *   "skip" (which would silently stop the agent) or "allow" (which would
 *   bypass the gate). Both silent behaviours are dangerous. The caller must
 *   handle the error explicitly.
 *
 * Adapter timeout:
 *   Default 500 ms. Queue-state lookups should be fast. A slow queue adapter
 *   is a sign of operator infrastructure issues that should NOT propagate to
 *   LLM-call latency. Timeout is configurable per-agent.
 *
 * Build Plan Reference: docs/BUILD_PLAN_V3_FULL_LIFECYCLE.md line 274
 * Budget-trio siblings: PR #12 (per-cycle), PR #30 (per-agent-daily)
 *
 * Compliance bindings:
 *   - HIPAA 164.312(b): Audit Controls -- idle-agent calls appear as anomalies
 *   - SOC 2 CC6.1: Resource control -- skip unnecessary inference spend
 *   - EU AI Act Art. 9: Risk management -- bound agent action to intent
 *   - ISO 42001 A.6.1.4: Resource management for AI systems
 *
 * @connexum/ai-governance
 */
import * as crypto from 'node:crypto';
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/**
 * Thrown by guardPreCall() when the adapter reports hasWork: false.
 * The caller should catch this and skip the LLM call entirely.
 * It is NOT an error condition -- it is an expected, recoverable signal.
 */
export class EmptyQueueSkipApiError extends Error {
    orgId;
    agentId;
    packBinding;
    constructor(opts) {
        super(`Agent '${opts.agentId}' in org '${opts.orgId}' has no pending work. ` +
            `LLM call skipped to preserve token budget.`);
        this.name = 'EmptyQueueSkipApiError';
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
        this.packBinding = opts.packBinding;
    }
}
/**
 * Thrown by guardPreCall() when the adapter's peek() does not resolve within
 * the configured timeout. Fails closed -- the LLM call is rejected until
 * the operator fixes their adapter latency.
 */
export class QueueAdapterTimeoutError extends Error {
    orgId;
    agentId;
    timeoutMs;
    packBinding;
    constructor(opts) {
        super(`Queue adapter for agent '${opts.agentId}' in org '${opts.orgId}' did not resolve ` +
            `within ${opts.timeoutMs} ms. Queue-state lookups must be fast. ` +
            `Slow adapters indicate operator infrastructure issues. LLM call rejected (fail-closed).`);
        this.name = 'QueueAdapterTimeoutError';
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
        this.timeoutMs = opts.timeoutMs;
        this.packBinding = opts.packBinding;
    }
}
/**
 * Thrown when configure() is called with an empty packBinding.
 * Per pack-binding-primacy: every governance record must carry at least one
 * compliance pack binding.
 */
export class EmptyQueuePackBindingError extends Error {
    operation;
    constructor(operation) {
        super(`Operation '${operation}' requires a non-empty packBinding. ` +
            `Per the Connexum AI Governance pack-binding-primacy invariant, ` +
            `every governance record must carry at least one compliance pack binding.`);
        this.name = 'EmptyQueuePackBindingError';
        this.operation = operation;
    }
}
/**
 * Thrown when configure() is called by an identity that does not hold the
 * 'governance.configure' permission (PT-SKIP-API-3: opt-in bypass prevention).
 */
export class ConfigurePermissionError extends Error {
    operatorId;
    reason;
    constructor(operatorId, reason) {
        super(`configure() denied for identity '${operatorId}': ${reason}. ` +
            `Only identities with governance.configure permission may register ` +
            `a queue adapter. Agent-runtime identities are rejected.`);
        this.name = 'ConfigurePermissionError';
        this.operatorId = operatorId;
        this.reason = reason;
    }
}
// ---------------------------------------------------------------------------
// SkipApiEmptyQueueEnforcer
// ---------------------------------------------------------------------------
/** Default adapter timeout in milliseconds. */
export const DEFAULT_ADAPTER_TIMEOUT_MS = 500;
/**
 * Ratio threshold above which a QUEUE_PEEK_INCONSISTENT warning event is
 * emitted. If peeksClaimingWork / actualLlmCalls > INCONSISTENCY_THRESHOLD,
 * the adapter is reporting hasWork:true significantly more than LLM calls are
 * actually occurring, which may indicate a compromised adapter always returning
 * hasWork:true to keep the gate open. (PT-SKIP-API-1)
 *
 * Evaluated at every skip event (because a skip is direct evidence the adapter
 * returned hasWork:false at least once, giving us a consistent baseline to
 * compare against).
 *
 * Minimum sample size before firing: 10 peeks to avoid false positives on
 * low-volume agents.
 */
export const INCONSISTENCY_THRESHOLD = 5.0;
export const INCONSISTENCY_MIN_SAMPLES = 10;
export class SkipApiEmptyQueueEnforcer {
    /** Per-agent registered adapters. Key: `${orgId}::${agentId}` */
    agents = new Map();
    /**
     * Audit event callback. Wire to AuditIntegrity.append() in production.
     */
    onAudit;
    /**
     * SHA chain state for audit events. Simple in-memory chain for the
     * enforcer's own events. In production wire to the external AuditIntegrity chain.
     */
    lastSha = '';
    /**
     * Optional permission check function injected by the operator.
     * Signature mirrors OperatorRoles.checkPermission.
     *
     * If not provided, any caller can configure the gate -- appropriate for
     * test environments. In production, inject OperatorRoles.checkPermission
     * to enforce that only operators hold governance.configure.
     */
    checkPermission;
    constructor(opts = {}) {
        this.onAudit = opts.onAudit ?? (() => undefined);
        this.checkPermission = opts.checkPermission;
    }
    // ---------------------------------------------------------------------------
    // Public API
    // ---------------------------------------------------------------------------
    /**
     * Register a queue adapter for an agent.
     *
     * Must be called before guardPreCall() has any effect for this agent.
     * If not called, guardPreCall() is a no-op pass-through (opt-in semantics).
     *
     * PT-SKIP-API-3: configure() checks the callerIdentity holds
     * 'governance.configure' permission when a checkPermission function was
     * injected. Agent-runtime identities (prefixed 'agent:' or 'runtime:') are
     * always rejected, even without an injected checker.
     *
     * @param orgId           Tenant / organization ID.
     * @param agentId         Agent identifier.
     * @param callerIdentity  Identity of the operator performing configuration.
     * @param queueAdapter    Operator-implemented adapter.
     * @param packBinding     Non-empty compliance pack binding. (Pack-binding primacy.)
     * @param adapterTimeoutMs  Timeout for peek() calls. Default 500 ms.
     */
    configure(orgId, agentId, callerIdentity, queueAdapter, packBinding, adapterTimeoutMs = DEFAULT_ADAPTER_TIMEOUT_MS) {
        if (!packBinding || packBinding.length === 0) {
            throw new EmptyQueuePackBindingError('configure');
        }
        // PT-SKIP-API-3: reject agent-runtime identities unconditionally.
        if (this.isAgentRuntimeIdentity(callerIdentity)) {
            throw new ConfigurePermissionError(callerIdentity, 'agent-runtime identities (prefixed agent: or runtime:) may not register queue adapters. ' +
                'Only operator identities with governance.configure permission are permitted.');
        }
        // PT-SKIP-API-3: check injected permission function if present.
        if (this.checkPermission) {
            const check = this.checkPermission(callerIdentity, 'governance.configure');
            if (!check.allowed) {
                throw new ConfigurePermissionError(callerIdentity, check.reason);
            }
        }
        const key = this.key(orgId, agentId);
        const existing = this.agents.get(key);
        this.agents.set(key, {
            adapter: queueAdapter,
            packBinding,
            adapterTimeoutMs,
            skippedTotal: existing?.skippedTotal ?? 0,
            skippedHourBucket: existing?.skippedHourBucket ?? 0,
            hourBucketKey: existing?.hourBucketKey ?? this.currentHourBucket(),
            lastSkipAt: existing?.lastSkipAt ?? null,
            peeksClaimingWork: existing?.peeksClaimingWork ?? 0,
            actualLlmCalls: existing?.actualLlmCalls ?? 0,
        });
        this.emit({
            type: 'QUEUE_ADAPTER_CONFIGURED',
            severity: 'NOTICE',
            orgId,
            agentId,
            packBinding,
            callerIdentity,
            adapterTimeoutMs,
        });
    }
    /**
     * Pre-call gate. Call this BEFORE every LLM call.
     *
     * Behaviour:
     *   - Agent NOT configured: no-op, returns { allowed: true }. No event emitted.
     *     (Opt-in semantics. Agents without a registered adapter are unaffected.)
     *   - Adapter reports hasWork: true: returns { allowed: true, workCount }.
     *     Increments the actualLlmCalls counter for consistency tracking.
     *   - Adapter reports hasWork: false: throws EmptyQueueSkipApiError.
     *     Caller catches and skips the LLM call. recordSkippedCall() is called
     *     internally to emit the audit event and update counters.
     *   - Adapter throws: rethrows. Gate fails closed. NOT silently skipped or
     *     silently allowed -- both silent paths are dangerous.
     *   - Adapter does not resolve within adapterTimeoutMs: throws
     *     QueueAdapterTimeoutError. Fails closed. (PT-SKIP-API-2)
     *
     * @param orgId     Tenant / organization ID.
     * @param agentId   Agent identifier.
     */
    async guardPreCall(orgId, agentId) {
        const key = this.key(orgId, agentId);
        const state = this.agents.get(key);
        // Not configured: no-op pass-through (opt-in semantics).
        if (!state) {
            return { allowed: true };
        }
        // Call adapter with timeout. (PT-SKIP-API-2)
        let peekResult;
        try {
            peekResult = await this.peekWithTimeout(state.adapter, state.adapterTimeoutMs, orgId, agentId, state.packBinding);
        }
        catch (err) {
            // Rethrow. Fail closed. Do NOT swallow adapter errors.
            throw err;
        }
        if (!peekResult.hasWork) {
            // Empty queue: skip the LLM call.
            await this.recordSkippedCallInternal(state, orgId, agentId, 'queue-empty', peekResult);
            throw new EmptyQueueSkipApiError({
                orgId,
                agentId,
                packBinding: state.packBinding,
            });
        }
        // Queue has work: allow. Track for consistency (PT-SKIP-API-1).
        state.peeksClaimingWork += 1;
        state.actualLlmCalls += 1;
        return { allowed: true, workCount: peekResult.workCount };
    }
    /**
     * Emit an audit record for a manually recorded skip.
     *
     * Operators may call this when they skip an LLM call outside the
     * guardPreCall() path (e.g., in a wrapper that catches EmptyQueueSkipApiError
     * and wants to add extra context). guardPreCall() calls this internally.
     *
     * @param orgId       Tenant / organization ID.
     * @param agentId     Agent identifier.
     * @param reason      Human-readable reason string.
     * @param packBinding Non-empty compliance pack binding.
     */
    recordSkippedCall(orgId, agentId, reason, packBinding) {
        if (!packBinding || packBinding.length === 0) {
            throw new EmptyQueuePackBindingError('recordSkippedCall');
        }
        this.emit({
            type: 'API_SKIPPED_EMPTY_QUEUE',
            severity: 'NOTICE',
            orgId,
            agentId,
            packBinding,
            reason,
        });
    }
    /**
     * Return per-agent skip telemetry for operator dashboards.
     *
     * @param orgId     Tenant / organization ID.
     * @param agentId   Agent identifier.
     */
    getStats(orgId, agentId) {
        const key = this.key(orgId, agentId);
        const state = this.agents.get(key);
        if (!state) {
            return {
                skippedToday: 0,
                skippedThisHour: 0,
                lastSkipAt: null,
                configured: false,
            };
        }
        return {
            skippedToday: state.skippedTotal,
            skippedThisHour: this.resolveHourBucket(state),
            lastSkipAt: state.lastSkipAt,
            configured: true,
        };
    }
    // ---------------------------------------------------------------------------
    // Internal helpers
    // ---------------------------------------------------------------------------
    key(orgId, agentId) {
        return `${orgId}::${agentId}`;
    }
    currentHourBucket() {
        return Math.floor(Date.now() / 3_600_000);
    }
    /**
     * Resolve the skip count for the current hour bucket.
     * Resets the bucket counter if we have moved to a new hour.
     */
    resolveHourBucket(state) {
        const currentBucket = this.currentHourBucket();
        if (state.hourBucketKey !== currentBucket) {
            state.hourBucketKey = currentBucket;
            state.skippedHourBucket = 0;
        }
        return state.skippedHourBucket;
    }
    /**
     * Execute peek() with a timeout. (PT-SKIP-API-2)
     * Fails closed: timeout throws QueueAdapterTimeoutError (not "skip" or "allow").
     */
    peekWithTimeout(adapter, timeoutMs, orgId, agentId, packBinding) {
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                reject(new QueueAdapterTimeoutError({ orgId, agentId, timeoutMs, packBinding }));
            }, timeoutMs);
            adapter.peek().then((result) => {
                clearTimeout(timer);
                resolve(result);
            }, (err) => {
                clearTimeout(timer);
                reject(err);
            });
        });
    }
    /**
     * Internal skip recording. Updates counters and emits the audit event.
     * Also runs the PT-SKIP-API-1 consistency check.
     */
    recordSkippedCallInternal(state, orgId, agentId, reason, peekResult) {
        const currentBucket = this.currentHourBucket();
        if (state.hourBucketKey !== currentBucket) {
            state.hourBucketKey = currentBucket;
            state.skippedHourBucket = 0;
        }
        state.skippedTotal += 1;
        state.skippedHourBucket += 1;
        state.lastSkipAt = process.hrtime.bigint();
        this.emit({
            type: 'API_SKIPPED_EMPTY_QUEUE',
            severity: 'NOTICE',
            orgId,
            agentId,
            packBinding: state.packBinding,
            reason,
            workCount: peekResult.workCount,
            oldestPendingMs: peekResult.oldestPendingMs,
            skippedTotal: state.skippedTotal,
        });
        // PT-SKIP-API-1: consistency check.
        // A compromised adapter that always returns hasWork:true would never
        // trigger this code path (since this runs only on hasWork:false).
        // The check fires when a skip DOES occur but peeksClaimingWork is wildly
        // high relative to actualLlmCalls, suggesting prior peek inflation.
        this.checkPeekConsistency(state, orgId, agentId);
    }
    /**
     * PT-SKIP-API-1: detect adapter peek inflation.
     *
     * If the ratio of peeks claiming work to actual LLM calls diverges beyond
     * INCONSISTENCY_THRESHOLD (default 5x), the adapter may be compromised --
     * always reporting hasWork:true to keep the gate open while the agent is
     * actually calling the LLM far less frequently.
     *
     * This is a probabilistic signal, not a hard gate. A QUEUE_PEEK_INCONSISTENT
     * warning is emitted for operator review.
     *
     * Minimum sample size: INCONSISTENCY_MIN_SAMPLES (default 10). Avoids
     * false positives on low-volume or newly started agents.
     */
    checkPeekConsistency(state, orgId, agentId) {
        if (state.peeksClaimingWork < INCONSISTENCY_MIN_SAMPLES)
            return;
        if (state.actualLlmCalls === 0)
            return;
        const ratio = state.peeksClaimingWork / state.actualLlmCalls;
        if (ratio > INCONSISTENCY_THRESHOLD) {
            this.emit({
                type: 'QUEUE_PEEK_INCONSISTENT',
                severity: 'WARNING',
                orgId,
                agentId,
                packBinding: state.packBinding,
                peeksClaimingWork: state.peeksClaimingWork,
                actualLlmCalls: state.actualLlmCalls,
                ratio,
                threshold: INCONSISTENCY_THRESHOLD,
                detail: 'Adapter peeks claiming hasWork:true significantly exceed actual LLM calls. ' +
                    'Possible compromised adapter always returning hasWork:true to keep the gate open. ' +
                    'Review adapter implementation. (PT-SKIP-API-1)',
            });
        }
    }
    /**
     * Determine if the given identity is an agent-runtime identity.
     * Heuristic: agent-runtime IDs are prefixed with 'agent:' or 'runtime:'.
     * In production, wire to OperatorRoles for a hard check.
     */
    isAgentRuntimeIdentity(identity) {
        return identity.startsWith('agent:') || identity.startsWith('runtime:');
    }
    /**
     * Emit an audit event with SHA-256 chaining.
     * Swallows errors from the onAudit callback to avoid breaking the enforcement path.
     */
    emit(fields) {
        const payload = {
            ...fields,
            timestamp: new Date().toISOString(),
            prevSha: this.lastSha,
        };
        const entryStr = JSON.stringify(payload);
        const sha = crypto.createHash('sha256').update(entryStr).digest('hex');
        this.lastSha = sha;
        try {
            this.onAudit(payload);
        }
        catch {
            // Swallow -- enforcement path must not fail due to audit callback errors.
        }
    }
}
//# sourceMappingURL=skip-api-empty-queue.js.map