/**
 * src/skill-loop/sign-off.ts
 *
 * Article 14 human oversight mechanism for skill promotion.
 *
 * Invariants:
 *   - Audit trail: { approver_id, timestamp, decision, rationale, signed_hash }
 *   - signed_hash = SHA-256(candidateId + decision + rationale + isoTimestamp)
 *   - Escalation: approver unavailable >72h → auto-REJECTED (never auto-approved)
 *   - Multiple approvers possible; one sign-off is sufficient for promotion.
 *
 * @module skill-loop/sign-off
 */
import { createHash } from "node:crypto";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function sha256Hex(input) {
    return createHash("sha256").update(input, "utf8").digest("hex");
}
function isoNow(overrideDate) {
    return (overrideDate ?? new Date()).toISOString();
}
/** Compute the signed hash per the canonical formula. */
function computeSignedHash(candidateId, decision, rationale, timestamp) {
    return sha256Hex(`${candidateId}${decision}${rationale}${timestamp}`);
}
// ---------------------------------------------------------------------------
// SignOffManager
// ---------------------------------------------------------------------------
/**
 * Manages pending approval requests and sign-off records.
 *
 * In production, records should be persisted to the DB. This implementation
 * is in-memory and suitable for tests and single-process environments.
 */
export class SignOffManager {
    records = new Map();
    pending = new Map();
    /** 72-hour escalation deadline in milliseconds. */
    static ESCALATION_DEADLINE_MS = 72 * 60 * 60 * 1_000;
    // ── Request ─────────────────────────────────────────────────────────────────
    /**
     * Create a pending approval request for a candidate.
     *
     * If a request already exists and is not expired, returns the existing one.
     */
    requestSignOff(candidateId, approverId = null, nowDate) {
        const existing = this.pending.get(candidateId);
        if (existing && !this.isExpired(existing, nowDate)) {
            return existing;
        }
        const requestedAt = isoNow(nowDate);
        const deadline = new Date((nowDate ?? new Date()).getTime() + SignOffManager.ESCALATION_DEADLINE_MS);
        const request = {
            candidateId,
            requestedAt,
            approverId,
            deadlineAt: deadline.toISOString(),
        };
        this.pending.set(candidateId, request);
        return request;
    }
    // ── Approve / Reject ────────────────────────────────────────────────────────
    /**
     * Record a sign-off decision (approved or rejected).
     *
     * @throws If rationale is empty or candidateId is missing.
     */
    recordDecision(candidateId, approverId, decision, rationale, nowDate) {
        if (!rationale || rationale.trim().length === 0) {
            throw new Error(`sign-off: rationale is required for decision "${decision}" on candidate "${candidateId}"`);
        }
        const timestamp = isoNow(nowDate);
        const signedHash = computeSignedHash(candidateId, decision, rationale, timestamp);
        const record = {
            candidateId,
            approverId,
            timestamp,
            decision,
            rationale,
            signedHash,
        };
        const existing = this.records.get(candidateId) ?? [];
        existing.push(record);
        this.records.set(candidateId, existing);
        // Remove the pending request once a decision is recorded.
        this.pending.delete(candidateId);
        return record;
    }
    // ── Escalation ──────────────────────────────────────────────────────────────
    /**
     * Check all pending requests and auto-reject any that have exceeded the 72h deadline.
     *
     * @returns Array of auto-rejection records created.
     */
    escalateExpired(nowDate) {
        const rejections = [];
        for (const [candidateId, request] of this.pending.entries()) {
            if (this.isExpired(request, nowDate)) {
                const record = this.recordDecision(candidateId, "system:escalation", "rejected", "Auto-rejected: approver unavailable >72h (Art. 14 escalation — never auto-approved)", nowDate);
                rejections.push(record);
            }
        }
        return rejections;
    }
    // ── Queries ─────────────────────────────────────────────────────────────────
    /**
     * Returns the first approved sign-off for a candidate, or null if none exists.
     * One sign-off is sufficient for promotion.
     */
    getApproval(candidateId) {
        const records = this.records.get(candidateId) ?? [];
        return (records.find((r) => r.decision === "approved" && r.approverId !== "system:escalation") ?? null);
    }
    /**
     * Returns all sign-off records for a candidate (audit trail).
     */
    getAuditTrail(candidateId) {
        return this.records.get(candidateId) ?? [];
    }
    /**
     * Returns true if a pending request exists and has not expired.
     */
    hasPending(candidateId, nowDate) {
        const request = this.pending.get(candidateId);
        if (!request)
            return false;
        return !this.isExpired(request, nowDate);
    }
    // ── Verification ────────────────────────────────────────────────────────────
    /**
     * Verify the integrity of a sign-off record by recomputing its hash.
     */
    verifyRecord(record) {
        const expected = computeSignedHash(record.candidateId, record.decision, record.rationale, record.timestamp);
        return expected === record.signedHash;
    }
    // ── Private ─────────────────────────────────────────────────────────────────
    isExpired(request, nowDate) {
        const deadline = new Date(request.deadlineAt).getTime();
        const now = (nowDate ?? new Date()).getTime();
        return now > deadline;
    }
}
//# sourceMappingURL=sign-off.js.map