import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { CryptoService } from './CryptoService';
import { isSeverityAtLeast } from '../utils/severity';
import { evaluatePolicyRisk, policyInputs, type PolicyBlockReason } from '../utils/policy-risk';
import { applySuppressions } from '../utils/suppression-filter';
import { SENCHO_ROLLBACK_HOLD_SQL_LIKE } from '../utils/senchoRollbackHold';
import type { AuditStatsInput } from './AuditAnomalyService';
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
import { invalidateNodeNetworkingAggregate } from './network/networkingAggregateCache';
import { HIGH_EPSS_THRESHOLD } from './securityPosture';
import type { BackendScheduledAction } from './scheduledActionRegistry';
import { stackPatternMatches } from '../helpers/stackPattern';
import {
    parseStoredNotificationSchedule,
    type NotificationSchedule,
} from '../helpers/notificationSchedule';
import { readSnapshotFileRow, type SnapshotFileReadResult, type SnapshotFileRow } from '../helpers/snapshotFileDecrypt';
import { sanitizeForLog } from '../utils/safeLog';
import type { GitSourceManifestState } from '../types/gitProjectManifest';
import type { RollbackOperationKind } from '../types/rollbackGeneration';
import { collectImageIds, parseServicesJsonStrict } from './recoveryServicesJson';
import { GITOPS_SCHEMA_SQL } from './gitops/schema';

export type { SnapshotFileReadResult } from '../helpers/snapshotFileDecrypt';
export type { RollbackOperationKind } from '../types/rollbackGeneration';

function isPilotMode(): boolean {
    return process.env.SENCHO_MODE === 'pilot';
}

export interface Agent {
    id?: number;
    type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
    url: string;
    enabled: boolean;
    config?: string | null;
    /** Optional user-authored JSON payload template; null = built-in payload. */
    payload_template?: string | null;
}

export interface GlobalSetting {
    key: string;
    value: string;
}

/**
 * Per-stack image-update check outcome. 'ok' = every checkable image was
 * reached; 'partial' = some checkable images errored; 'failed' = no checkable
 * image could be reached (status undeterminable). Distinguishes a failed check
 * from a confirmed "up to date".
 */
export type StackCheckStatus = 'ok' | 'partial' | 'failed';

/**
 * Per-service check outcome persisted in stack_update_status.services_json.
 * Distinct from StackCheckStatus: a service with no checkable image ref is
 * `not_checkable`, which never counts as a check failure at the aggregate
 * (stack-level) status.
 */
export type ServiceCheckStatus = 'ok' | 'partial' | 'failed' | 'not_checkable';

export interface StackServiceStatus {
    service: string;
    image: string | null;
    runtimeImages?: string[];
    hasUpdate: boolean;
    checkStatus: ServiceCheckStatus;
    lastError: string | null;
}

export interface StackUpdateDetail {
    hasUpdate: boolean;
    checkStatus: StackCheckStatus;
    lastError: string | null;
    checkedAt: number;
    /** Per-service breakdown; omitted when the stack has no persisted per-service data (no effective model available yet, or corrupt JSON). */
    services?: StackServiceStatus[];
}

const SERVICES_JSON_VERSION = 1;
const DEFAULT_RECOVERY_RETENTION_DAYS = 7;
const DEFAULT_RECOVERY_MAX_GENERATIONS = 0;

function isStackServiceStatus(value: unknown): value is StackServiceStatus {
    if (!value || typeof value !== 'object') return false;
    const v = value as Record<string, unknown>;
    return typeof v.service === 'string'
        && (v.image === null || typeof v.image === 'string')
        && typeof v.hasUpdate === 'boolean'
        && (v.checkStatus === 'ok' || v.checkStatus === 'partial' || v.checkStatus === 'failed' || v.checkStatus === 'not_checkable')
        && (v.lastError === null || typeof v.lastError === 'string');
}

/** Parses stack_update_status.services_json safely; a missing, corrupt, or version-mismatched value yields an empty list rather than throwing. */
function parseServicesJson(raw: string | null | undefined): StackServiceStatus[] {
    if (!raw) return [];
    try {
        const parsed = JSON.parse(raw) as { version?: unknown; services?: unknown };
        if (parsed?.version !== SERVICES_JSON_VERSION || !Array.isArray(parsed.services)) return [];
        return parsed.services.filter(isStackServiceStatus);
    } catch {
        return [];
    }
}

/** Write generation embedded in services_json; 0 when missing or unreadable. */
function parseServicesJsonGeneration(raw: string | null | undefined): number {
    if (!raw) return 0;
    try {
        const parsed = JSON.parse(raw) as { version?: unknown; generation?: unknown };
        if (parsed?.version !== SERVICES_JSON_VERSION) return 0;
        return typeof parsed.generation === 'number' && Number.isFinite(parsed.generation)
            ? parsed.generation
            : 0;
    } catch {
        return 0;
    }
}

function stringifyServicesJson(services: StackServiceStatus[], generation: number): string {
    return JSON.stringify({ version: SERVICES_JSON_VERSION, generation, services });
}

export interface StackAlert {
    id?: number;
    stack_name: string;
    service_name: string | null;
    metric: string;
    operator: string;
    threshold: number;
    duration_mins: number;
    cooldown_mins: number;
    last_fired_at?: number;
}

export type NodeMode = 'proxy' | 'pilot_agent';

export interface AutoHealPolicy {
    id?: number;
    node_id: number;
    proxy_entitled_until: number;
    stack_name: string;
    service_name: string | null;
    unhealthy_duration_mins: number;
    cooldown_mins: number;
    max_restarts_per_hour: number;
    auto_disable_after_failures: number;
    enabled: number;
    consecutive_failures: number;
    last_fired_at: number;
    created_at: number;
    updated_at: number;
}

export interface AutoHealHistoryEntry {
    id?: number;
    policy_id: number;
    stack_name: string;
    service_name: string | null;
    container_name: string;
    container_id: string;
    action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled' | 'docker_unavailable';
    reason: string;
    success: number;
    error: string | null;
    timestamp: number;
}

/** Operator-authored fields of a stack dossier (everything the user types). */
export interface StackDossierFields {
    purpose: string;
    owner: string;
    access_urls: string;
    static_ip: string;
    vlan: string;
    firewall_notes: string;
    reverse_proxy_notes: string;
    backup_notes: string;
    upgrade_notes: string;
    recovery_notes: string;
    custom_notes: string;
}

/** A persisted stack dossier row: operator fields plus identity and timestamps. */
export interface StackDossier extends StackDossierFields {
    id?: number;
    node_id: number;
    stack_name: string;
    /** SHA-256 of the compose file's UTF-8 text at the last deploy through Sencho (baseline for temporal drift). */
    source_hash?: string | null;
    /** SHA-256 of the parsed compose model at the last deploy (ignores comments/whitespace). */
    rendered_hash?: string | null;
    /** When the drift ledger was last reconciled for this stack (re-check, deploy, or background scan); null if never. */
    last_drift_check_at?: number | null;
    created_at: number;
    updated_at: number;
}

/** A stored Compose Doctor run. Replace-on-run keeps one row per (node, stack). */
export interface PreflightRunRow {
    id: string;
    node_id: number;
    stack_name: string;
    source_hash: string | null;
    rendered_hash: string | null;
    /** JSON map of service name to image ref at run time (for until_image_change acks). */
    service_images: string | null;
    status: string;
    highest_severity: string | null;
    created_at: number;
    created_by: string | null;
}

/** Per-stack per-service Compose exposure descriptor (one row per node+stack). */
export interface StackExposureRow {
    node_id: number;
    stack_name: string;
    /** JSON StackExposure from preflight/exposure.ts. */
    descriptor: string;
    computed_at: number;
}

/** One health-gate observation run, keyed by `trigger_action`. */
export interface HealthGateRunRow {
    id: string;
    node_id: number;
    stack_name: string;
    /** Named trigger_action because TRIGGER is reserved in SQLite. */
    trigger_action: 'update' | 'deploy' | 'service_update' | 'service_restore' | 'recovery';
    status: 'observing' | 'passed' | 'failed' | 'unknown';
    reason: string | null;
    window_seconds: number;
    /** JSON array of per-container end states for display. */
    containers_json: string;
    started_at: number;
    ended_at: number | null;
    created_by: string | null;
    /** Additive; legacy rows default to stack. */
    target_scope: 'stack' | 'service';
    service_name: string | null;
    failure_source: 'primary' | 'collateral' | null;
    /**
     * Reserved for the GitOps deploy path: the generation live when this run
     * started. No writer populates it yet, so it is currently always null.
     */
    deployed_generation_id?: string | null;
}

/** Pre-update image snapshot enabling a manual per-service restore after a service-scoped update. */

/** Full-stack update recovery generation. Separate from service_update_recovery. */
export interface StackUpdateRecoveryGenerationRow {
    id: string;
    node_id: number;
    stack_name: string;
    status: 'candidate' | 'active' | 'restored_current' | 'superseded' | 'abandoned' | 'recovery_required';
    phase: 'captured' | 'acquired' | 'handoff_committed' | 'reconciling' | 'immediate_verified';
    is_current: number;
    backup_slot_id: string | null;
    /** Generation content key (often equal to backup_slot_id / generation id). */
    content_path: string | null;
    /** Capture trigger: update | deployment | git_apply | manual_backup | unknown. */
    operation_kind: RollbackOperationKind | null;
    override_path: string | null;
    services_json: string;
    health_gate_id: string | null;
    gate_retain_until: number | null;
    artifact_expires_at: number | null;
    operation_lease_expires_at: number | null;
    created_at: number;
    updated_at: number;
    created_by: string | null;
    artifacts_retired: number;
    /** Set when an operator manually released rollback protection early (see releaseStackUpdateRecoveryGeneration). */
    released_at: number | null;
    released_by: string | null;
    gitops_generation_id?: string | null;
    gitops_artifact_set_id?: string | null;
    gitops_source_acceptance_ref?: string | null;
}

/** Durable cleanup tombstone for stack/node deletion artifact sweep. */
export interface StackUpdateCleanupPendingRow {
    id: string;
    node_id: number | null;
    stack_name: string | null;
    status: 'prepared' | 'ready' | 'cancelled';
    target_kind: 'local_socket';
    rollback_tags_json: string;
    override_paths_json: string;
    prune_volumes_requested: number;
    /** Blueprint ID that authorized this deletion; null for manual deletes. */
    required_blueprint_id: number | null;
    created_at: number;
    updated_at: number;
}

export interface ServiceUpdateRecoveryRow {
    id: string;
    node_id: number;
    stack_name: string;
    service_name: string;
    /** JSON array of pre-update replica image snapshots (imageId + repoDigest when known). */
    replicas_json: string;
    majority_image_id: string;
    /** Audit/UI only: the tag the majority image is retagged onto during restore, never a policy scan target. */
    declared_image_ref: string;
    weak_floating_tag: number;
    health_gate_id: string | null;
    status: 'active' | 'restoring' | 'consumed' | 'expired' | 'invalidated';
    expires_at: number;
    claim_expires_at: number | null;
    created_at: number;
    created_by: string | null;
}

/** One finding within a stored preflight run. Never carries an environment value. */
export interface PreflightFindingRow {
    id: string;
    run_id: string;
    rule_id: string;
    severity: string;
    title: string;
    message: string;
    source_path: string | null;
    remediation: string | null;
    service: string | null;
    created_at: number;
}

export type PreflightAckExpiryMode = 'forever' | 'until_compose_change' | 'days' | 'until_image_change';

/** Operator acknowledgement of a specific Compose Doctor finding on a stack. */
export interface PreflightAcknowledgement {
    id: number;
    node_id: number;
    stack_name: string;
    rule_id: string;
    service: string | null;
    reason: string;
    expiry_mode: PreflightAckExpiryMode;
    expires_at: number | null;
    anchor_rendered_hash: string | null;
    anchor_image_ref: string | null;
    created_by: string;
    created_at: number;
}

/** A persisted drift finding: one service-scoped divergence, open until resolved. */
export interface StackDriftFindingRow {
    id: number;
    node_id: number;
    stack_name: string;
    service: string;
    finding_type: string;
    severity: string;
    message: string;
    expected_json: string | null;
    actual_json: string | null;
    detected_at: number;
    resolved_at: number | null;
}

export interface StackExposureIntentRow {
    id: number;
    node_id: number;
    stack_name: string;
    /** '' = the stack-level classification; otherwise a service name. */
    service: string;
    intent: ExposureIntent;
    updated_at: number;
    updated_by: string | null;
}

export interface Node {
    id: number;
    name: string;
    type: 'local' | 'remote';
    mode: NodeMode;
    compose_dir: string;
    is_default: boolean;
    status: 'online' | 'offline' | 'unknown';
    created_at: number;
    api_url?: string;
    api_token?: string;
    pilot_last_seen?: number | null;
    pilot_agent_version?: string | null;
    last_successful_contact?: number | null;
    cordoned: boolean;
    cordoned_at: number | null;
    cordoned_reason: string | null;
}

export interface StackRestartSummary {
    stackName: string;
    crash: number;
    autoheal: number;
    manual: number;
    total: number;
}

export interface PilotEnrollment {
    node_id: number;
    token_hash: string;
    expires_at: number;
    used_at: number | null;
}

export interface Label {
    id: number;
    node_id: number;
    name: string;
    color: string;
}

export type WebhookAction = 'deploy' | 'restart' | 'stop' | 'start' | 'pull' | 'git-pull';

export interface Webhook {
    id?: number;
    node_id: number;
    name: string;
    stack_name: string;
    action: WebhookAction;
    secret: string;
    enabled: boolean;
    created_at: number;
    updated_at: number;
}

export type GitSourceAuthType = 'none' | 'token' | 'deploy_key';

/**
 * The ordered set of local compose files actually materialized on disk for a
 * stack, plus the optional project directory. This is the deploy-time source of
 * truth: the deploy/lifecycle path, the mesh service, and the image-update path all
 * read it (never the configured `compose_paths`), so a saved-but-not-yet-applied config change does
 * not alter runtime until apply writes the files. `null` (the default) means a
 * plain single-file stack that uses docker compose auto-discovery, byte-identical
 * to pre-multi-file behavior.
 */
export interface GitSourceAppliedSpec {
    files: string[];            // ordered local relative compose filenames (files[0] is always compose.yaml)
    contextDir: string | null;  // optional project dir within the stack, passed as --project-directory
}

export interface StackGitSource {
    id?: number;
    stack_name: string;
    repo_url: string;
    branch: string;
    compose_path: string;           // primary repo path; kept in sync with compose_paths[0] for back-compat readers
    compose_paths: string[];        // ordered repo-relative compose paths; normalized to [compose_path] for legacy rows
    context_dir: string | null;     // configured project dir within the repo
    sync_env: boolean;
    env_path: string | null;
    auth_type: GitSourceAuthType;
    encrypted_token: string | null;
    encrypted_deploy_key: string | null;
    ssh_known_hosts_entry: string | null;
    ssh_host_key_fingerprint: string | null;
    encrypted_ca_bundle: string | null;
    auto_apply_on_webhook: boolean;
    auto_deploy_on_apply: boolean;
    last_applied_commit_sha: string | null;
    last_applied_content_hash: string | null;
    pending_commit_sha: string | null;
    pending_compose_content: string | null;
    pending_env_content: string | null;
    pending_fetched_at: number | null;
    last_debounce_at: number | null;
    applied_deploy_spec: GitSourceAppliedSpec | null;  // deploy-time materialized file set; null = single-file auto-discovery
    manifest_version: number | null;                   // cache of the managed-project manifest's manifestVersion (file is the source of truth)
    manifest_state: GitSourceManifestState | null;     // DB-only enum, wider than the file state; see types/gitProjectManifest.ts
    manifest_generation: string | null;                // stack-relative path of the applied generation dir
    pending_plan_fingerprint: string | null;
    pending_plan_blocked: boolean | null;
    pending_plan_summary: string | null;
    last_plan_fingerprint: string | null;
    last_plan_outcome: string | null;
    created_at: number;
    updated_at: number;
}

export interface WebhookExecution {
    id?: number;
    webhook_id: number;
    action: string;
    status: 'success' | 'failure';
    trigger_source: string | null;
    duration_ms: number | null;
    error: string | null;
    executed_at: number;
}

export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta' | 'oidc_custom';

// Source of truth for the role set. UserRole derives from it so the union and
// the runtime list cannot drift (adding a role here updates both).
export const USER_ROLES = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'] as const;
export type UserRole = typeof USER_ROLES[number];
/** Narrow an untrusted string (e.g. a proxied role header) to a known UserRole. */
export function isUserRole(value: unknown): value is UserRole {
  return typeof value === 'string' && (USER_ROLES as readonly string[]).includes(value);
}
export type ResourceType = 'stack' | 'node';

export interface User {
    id: number;
    username: string;
    password_hash: string;
    role: UserRole;
    auth_provider: AuthProvider;
    provider_id: string | null;
    email: string | null;
    token_version: number;
    created_at: number;
    updated_at: number;
}

export interface UserMfa {
    user_id: number;
    enabled: number;
    totp_secret_encrypted: string | null;
    backup_codes_json: string | null;
    sso_enforce_mfa: number;
    failed_attempts: number;
    locked_until: number | null;
    created_at: number;
    updated_at: number;
}

export type UserMfaUpdate = Partial<{
    enabled: boolean;
    totp_secret_encrypted: string | null;
    backup_codes_json: string | null;
    sso_enforce_mfa: boolean;
    failed_attempts: number;
    locked_until: number | null;
}>;

export interface RoleAssignment {
    id: number;
    user_id: number;
    role: UserRole;
    resource_type: ResourceType;
    resource_id: string;
    /** Required for stack scopes; null for node scopes. */
    node_id: number | null;
    created_at: number;
}

export interface SSOConfig {
    id: number;
    provider: string;
    enabled: number;
    config_json: string;
    created_at: number;
    updated_at: number;
}

export interface NotificationHistory {
    id?: number;
    level: 'info' | 'warning' | 'error';
    category?: string;
    message: string;
    timestamp: number;
    is_read: boolean;
    dispatch_error?: string;
    stack_name?: string;
    container_name?: string;
    actor_username?: string | null;
    suppression_match?: string | null;
    /** The GitOps operation this notification reports on, if any. */
    gitops_operation_id?: string | null;
    /**
     * Unique across all notifications when set. Lets fanout repair re-run
     * safely: inserting the same key again is a no-op that returns the
     * existing row instead of creating a duplicate.
     */
    dedupe_key?: string | null;
}

export interface FleetSnapshot {
    id: number;
    description: string;
    created_by: string;
    node_count: number;
    stack_count: number;
    skipped_nodes: string; // JSON: Array<{ nodeId; nodeName; reason }>
    skipped_stacks: string; // JSON: Array<{ nodeId; nodeName; stackName; reason }>
    created_at: number;
    /** 1 when the snapshot captured Stack Dossier metadata, 0 otherwise. The
     *  encrypted blob itself is never projected into list/detail rows; read it
     *  with getSnapshotDocumentation(). */
    has_documentation: number;
}

export interface FleetSnapshotFile {
    id: number;
    snapshot_id: number;
    node_id: number;
    node_name: string;
    stack_name: string;
    filename: string;
    content: string;
}

export type DriftMode = 'observe' | 'suggest' | 'enforce';
export type BlueprintClassification = 'stateless' | 'stateful' | 'unknown';
export type BlueprintDeploymentStatus =
    | 'pending'
    | 'pending_state_review'
    | 'deploying'
    | 'active'
    | 'drifted'
    | 'correcting'
    | 'failed'
    | 'withdrawing'
    | 'withdrawn'
    | 'evict_blocked'
    | 'name_conflict';

export type BlueprintSelector =
    | { type: 'labels'; any: string[]; all: string[] }
    | { type: 'nodes'; ids: number[] };

export interface NodeLabelRow {
    id: number;
    node_id: number;
    label: string;
    created_at: number;
}

export interface Blueprint {
    id: number;
    name: string;
    description: string | null;
    compose_content: string;
    selector: BlueprintSelector;
    drift_mode: DriftMode;
    classification: BlueprintClassification;
    classification_reasons: string[];
    enabled: boolean;
    revision: number;
    created_at: number;
    updated_at: number;
    created_by: string | null;
    pinned_node_id: number | null;
    approval_status: 'pending' | 'approved';
    approved_intent_fingerprint: string | null;
    approved_blast_json: string | null;
    approved_at: number | null;
    approved_by: string | null;
}

export interface BlueprintDeployment {
    id: number;
    blueprint_id: number;
    node_id: number;
    status: BlueprintDeploymentStatus;
    applied_revision: number | null;
    last_deployed_at: number | null;
    last_checked_at: number | null;
    last_drift_at: number | null;
    drift_summary: string | null;
    last_error: string | null;
}

export interface AuditLogEntry {
    id: number;
    timestamp: number;
    username: string;
    method: string;
    path: string;
    status_code: number;
    node_id: number | null;
    ip_address: string;
    summary: string;
    /** Hub operator for remote console_session bridges; null/absent for direct sessions. */
    acting_as?: string | null;
}

export interface SecretRow {
    id: number;
    name: string;
    description: string;
    current_version: number;
    created_at: number;
    created_by: string;
    updated_at: number;
}

export interface SecretVersionRow {
    id: number;
    secret_id: number;
    version: number;
    encrypted_payload: string;
    key_count: number;
    created_at: number;
    created_by: string;
    note: string;
}

export type SecretPushStatus = 'ok' | 'failed' | 'skipped';

export interface SecretPushRow {
    id: number;
    secret_id: number;
    version: number;
    push_id: string;
    node_id: number;
    stack_name: string;
    env_file_basename: string;
    status: SecretPushStatus;
    error: string;
    added_count: number;
    changed_count: number;
    unchanged_count: number;
    pushed_by: string;
    pushed_at: number;
}

export type ApiTokenScope = 'read-only' | 'deploy-only' | 'full-admin';

/** Map an API token's scope to the synthesized user role used during request authorization. */
export const API_TOKEN_SCOPE_TO_ROLE: Record<ApiTokenScope, UserRole> = {
    'read-only': 'viewer',
    'deploy-only': 'deployer',
    'full-admin': 'admin',
};

export interface ApiToken {
    id: number;
    token_hash: string;
    name: string;
    scope: ApiTokenScope;
    user_id: number;
    created_at: number;
    last_used_at: number | null;
    expires_at: number | null;
    revoked_at: number | null;
}

export interface ScheduledTask {
    id: number;
    name: string;
    target_type: 'stack' | 'fleet' | 'system' | 'container';
    target_id: string | null;
    node_id: number | null;
    action: BackendScheduledAction;
    cron_expression: string;
    enabled: number;
    created_by: string;
    /** The user ID who created this schedule. Null for legacy rows (pre-RBAC) where username resolution failed at migration time. */
    creator_user_id: number | null;
    created_at: number;
    updated_at: number;
    last_run_at: number | null;
    next_run_at: number | null;
    last_status: string | null;
    last_error: string | null;
    prune_targets: string | null;
    target_services: string | null;
    prune_label_filter: string | null;
    /** Optional dynamic target selector; currently only 'stack-label'. */
    selector_type: string | null;
    /** Selector payload (e.g. Stack Label name when selector_type is stack-label). */
    selector_value: string | null;
    delete_after_run?: number;
    // Absolute epoch-ms fire time for a one-time ('once') schedule. A 5-field
    // cron has no year field, so the chosen instant (including year) is persisted
    // here and survives disable/enable and edit, where the cron alone would
    // collapse to the next annual occurrence. Null for recurring schedules.
    run_at?: number | null;
}

export interface ScheduledTaskRun {
    id: number;
    task_id: number;
    started_at: number;
    completed_at: number | null;
    status: 'running' | 'success' | 'failure';
    output: string | null;
    error: string | null;
    triggered_by: 'scheduler' | 'manual';
}

export type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom';

export interface Registry {
    id: number;
    name: string;
    url: string;
    type: RegistryType;
    username: string;
    secret: string;
    aws_region: string | null;
    created_at: number;
    updated_at: number;
}

export interface NotificationRoute {
    id: number;
    name: string;
    node_id: number | null;
    stack_patterns: string[];
    label_ids: number[] | null;
    categories: string[] | null;
    levels: ('info' | 'warning' | 'error')[] | null;
    channel_type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
    channel_url: string;
    config?: string | null;
    priority: number;
    enabled: boolean;
    created_at: number;
    updated_at: number;
}

export type NotificationSuppressionAppliesTo = 'bell' | 'external' | 'both';

/** Hub-authored replica retraction: permanent never clears; recoverable is LWW-ordered. */
export type NotificationSuppressionRetractionKind = 'permanent' | 'recoverable';

export interface NotificationSuppressionRetraction {
    kind: NotificationSuppressionRetractionKind;
    /** Hub rule.updated_at at retraction time; compared only to hub versions, never receiver clocks. */
    source_updated_at: number;
}

export interface NotificationSuppressionRuleTombstone {
    id: number;
    deleted_at: number;
    kind: NotificationSuppressionRetractionKind;
    source_updated_at: number;
}

export type SuppressionReplicaWriteOutcome =
    | 'applied'
    | 'ignored_stale'
    | 'ignored_permanent_tombstone'
    | 'ignored_recoverable_watermark';

export type SuppressionReplicaDeleteOutcome = 'applied' | 'ignored_stale';

export interface NotificationSuppressionPendingRetraction {
    rule_id: number;
    node_id: number;
    kind: NotificationSuppressionRetractionKind;
    source_updated_at: number;
    created_at: number;
    updated_at: number;
    attempts: number;
    last_error: string | null;
}

/** Fail closed: anything other than recoverable is permanent. */
function normalizeSuppressionRetractionKind(kind: unknown): NotificationSuppressionRetractionKind {
    return kind === 'recoverable' ? 'recoverable' : 'permanent';
}

/** Coerce tombstone watermarks for merge/read; invalid values become 0. */
function safeTombstoneSourceUpdatedAt(value: unknown): number {
    const n = Number(value);
    return Number.isSafeInteger(n) ? n : 0;
}

export interface NotificationSuppressionRule {
    id: number;
    name: string;
    node_id: number | null;
    stack_patterns: string[];
    label_ids: number[] | null;
    categories: string[] | null;
    levels: ('info' | 'warning' | 'error')[] | null;
    applies_to: NotificationSuppressionAppliesTo;
    enabled: boolean;
    expires_at: number | null;
    /** Null = always in window (legacy). Ignored when scheduleInvalid. */
    schedule: NotificationSchedule | null;
    /** True when stored JSON is non-null but corrupt; must not suppress. */
    scheduleInvalid: boolean;
    created_at: number;
    updated_at: number;
}

export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';

/**
 * Decision recorded when a scan is evaluated against the matching policy.
 * Persisted as JSON on `vulnerability_scans.policy_evaluation` so the UI
 * can surface a banner on the scan details sheet without re-running the
 * match. `violated=false` rows exist too (informational), which is why
 * presence of the field does not mean "blocked".
 */
export interface PolicyEvaluation {
    policyId: number;
    policyName: string;
    maxSeverity: VulnSeverity;
    // Inputs that actually matched (severity / kev / fixable). Empty when not
    // violated. Lets the scan banner name the reason instead of always citing a
    // severity threshold the policy may not have gated on.
    reasons: PolicyBlockReason[];
    violated: boolean;
    evaluatedAt: number;
}

export interface VulnerabilityScan {
    id: number;
    node_id: number;
    image_ref: string;
    image_digest: string | null;
    scanned_at: number;
    total_vulnerabilities: number;
    critical_count: number;
    high_count: number;
    medium_count: number;
    low_count: number;
    unknown_count: number;
    fixable_count: number;
    secret_count: number;
    misconfig_count: number;
    scanners_used: string;
    highest_severity: VulnSeverity | null;
    os_info: string | null;
    trivy_version: string | null;
    scan_duration_ms: number | null;
    triggered_by: VulnScanTrigger;
    status: VulnScanStatus;
    error: string | null;
    stack_context: string | null;
    // JSON-encoded PolicyEvaluation; null if never evaluated.
    policy_evaluation: string | null;
}

// Fail-closed allowlist of the scanners_used values that ran the vulnerability
// scanner. Image scans store vuln/secret joins from normalizeScanners (TrivyService),
// while compose scans store 'config' directly; listing only the vuln-bearing sets
// means any other value (secret-only, config, or a future literal) is excluded, so
// a clean vuln read can never be a secret-only or config scan in disguise.
export const VULN_BEARING_SCANNER_SETS = ['vuln', 'vuln,secret'] as const;

const VALID_BLOCK_REASONS: ReadonlySet<PolicyBlockReason> = new Set(['severity', 'kev', 'fixable']);

export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEvaluation | null {
    if (!raw) return null;
    try {
        const parsed = JSON.parse(raw) as PolicyEvaluation;
        if (typeof parsed.policyId !== 'number' || typeof parsed.policyName !== 'string') {
            return null;
        }
        // Rows persisted before reason tracking lack `reasons`; default to empty
        // and keep only the known inputs so a stray stored value cannot reach the
        // banner or alert text.
        parsed.reasons = Array.isArray(parsed.reasons)
            ? parsed.reasons.filter((r) => VALID_BLOCK_REASONS.has(r))
            : [];
        return parsed;
    } catch {
        return null;
    }
}

export interface VulnerabilityDetail {
    id: number;
    scan_id: number;
    vulnerability_id: string;
    pkg_name: string;
    installed_version: string;
    fixed_version: string | null;
    severity: VulnSeverity;
    title: string | null;
    description: string | null;
    primary_url: string | null;
    // Scan-intrinsic enrichment captured from Trivy. Optional because older rows
    // (pre-enrichment) and callers that don't enrich omit them; the insert binds
    // null. `status` is the posture-relevant one (fixed / will_not_fix / ...).
    status?: string | null;
    cvss_score?: number | null;
    cvss_vector?: string | null;
    cvss_source?: string | null;
    vendor_severity?: VulnSeverity | null;
    purl?: string | null;
    pkg_path?: string | null;
    layer_digest?: string | null;
}

export interface SecretFinding {
    id: number;
    scan_id: number;
    rule_id: string;
    category: string | null;
    severity: VulnSeverity;
    title: string | null;
    target: string;
    start_line: number | null;
    end_line: number | null;
    match_excerpt: string | null;
}

export interface MisconfigFinding {
    id: number;
    scan_id: number;
    rule_id: string;
    check_id: string | null;
    severity: VulnSeverity;
    title: string | null;
    message: string | null;
    resolution: string | null;
    target: string;
    primary_url: string | null;
}

export interface ScanPolicy {
    id: number;
    name: string;
    node_id: number | null;
    node_identity: string;
    stack_pattern: string | null;
    max_severity: VulnSeverity;
    block_on_deploy: number;
    enabled: number;
    replicated_from_control: number;
    /** Block when an image's highest non-suppressed severity meets max_severity. */
    block_on_severity: number;
    /** Block when any non-suppressed CVE is in the CISA known-exploited (KEV) set. */
    block_on_kev: number;
    /** Block when any non-suppressed Critical/High finding has a fix available. */
    block_on_fixable: number;
    created_at: number;
    updated_at: number;
}

export interface FleetSyncStatus {
    node_id: number;
    resource: string;
    last_success_at: number | null;
    last_failure_at: number | null;
    last_error: string | null;
    sticky_error_code: string | null;
    sticky_error_expected: string | null;
    sticky_error_got: string | null;
}

export interface CveSuppression {
    id: number;
    cve_id: string;
    pkg_name: string | null;
    image_pattern: string | null;
    reason: string;
    created_by: string;
    created_at: number;
    expires_at: number | null;
    replicated_from_control: number;
    // Triage decision layered on the suppression. Optional on inputs (callers may
    // omit them; the insert defaults `status` to 'accepted', the back-compat value
    // for pre-triage rows). `justification` is an optional OpenVEX reason code.
    status?: string;
    justification?: string | null;
}

/**
 * Operator-acknowledged misconfiguration finding. Acknowledgements match by
 * rule_id and an optional stack_pattern glob, are applied at read time, and
 * never modify the persisted finding row. Mirrors `cve_suppressions` shape.
 */
export interface MisconfigAcknowledgement {
    id: number;
    rule_id: string;
    stack_pattern: string | null;
    reason: string;
    created_by: string;
    created_at: number;
    expires_at: number | null;
    replicated_from_control: number;
}

/** Read-time exploit intelligence joined to a CVE id (CveIntelService cache). */
export interface CveIntel {
    kev: boolean;
    kevDate: string | null;
    epssScore: number | null;
    epssPercentile: number | null;
}

export interface ScanSummary {
    image_ref: string;
    highest_severity: VulnSeverity | null;
    total: number;
    critical: number;
    high: number;
    medium: number;
    low: number;
    unknown: number;
    fixable: number;
    secret_count: number;
    misconfig_count: number;
    scanned_at: number;
    scan_id: number;
}

// Audit log buffering: writes are batched into a transaction either after
// AUDIT_LOG_FLUSH_INTERVAL_MS or once the buffer hits AUDIT_LOG_FLUSH_THRESHOLD,
// whichever comes first. Without this, every mutating /api/* request runs an
// individual INSERT and serializes against other writers under burst load
// (SQLite's single-writer model). All read paths (getAuditLogs,
// getAuditLogsInRange, cleanupOldAuditLogs) drain the buffer first so callers
// always observe a consistent view.
const AUDIT_LOG_FLUSH_INTERVAL_MS = 1_000;
const AUDIT_LOG_FLUSH_THRESHOLD = 100;

// Upper bound on the rows the anomaly baseline / stats computations pull into
// memory for a single request. The audit table grows unbounded within the
// retention window, and the analysis paths run on every list page and every
// stats refresh, so without a cap a busy fleet would scan the whole window into
// a JS array each time. When the window holds more than this, the most-recent
// rows are used and baselines become an approximation over recent activity.
export const AUDIT_ANOMALY_HISTORY_CAP = 20_000;

export const PILOT_METRICS_COUNTERS_KEY = 'pilot_metrics_counters';

export class DatabaseService {
    private static instance: DatabaseService;
    private db: Database.Database;
    // Cache of the global_settings table, populated on first read and
    // invalidated by updateGlobalSetting(). Hot paths (auth middleware,
    // WS upgrade, the audit-log debug gate) read this on every request,
    // so the round-trip to SQLite is worth eliminating. Assumes this
    // process is the sole writer to global_settings; sidecar tools that
    // edit the row directly will not invalidate the cache.
    // Sidecar CLI writes still leave this cache stale. authentication_mode
    // recovery works because getAuthenticationMode uses getGlobalSettingFresh,
    // not because this key is omitted from the cache.
    private cachedGlobalSettings: Readonly<Record<string, string>> | null = null;
    private auditLogBuffer: Array<Omit<AuditLogEntry, 'id'>> = [];
    private auditLogFlushTimer: ReturnType<typeof setTimeout> | null = null;
    private auditLogInsertStmt: Database.Statement | null = null;

    private constructor() {
        const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');

        if (!fs.existsSync(dataDir)) {
            fs.mkdirSync(dataDir, { recursive: true });
        }

        const dbPath = path.join(dataDir, 'sencho.db');
        this.db = new Database(dbPath);

        this.initSchema();
        this.migrateJsonConfig(dataDir);
        this.migrateAdminToUsersTable();
        this.migrateEncryptNodeTokens();
        this.migrateSSOColumns();
        this.migrateRegistries();
        this.migrateRegistryDelivery();
        this.migrateRoleAssignments();
        this.migrateNotificationRoutes();
        this.migrateNotificationRoutesNodeId();
        this.migrateNotificationRoutesMatchers();
        this.migrateNotificationChannelConfig();
        this.migrateNotificationRouteLevels();
        this.migrateNotificationSuppressionRules();
        this.migrateNotificationHistoryContext();
        this.migrateScanPolicyFleetColumns();
        this.migrateScanPolicyRiskColumns();
        this.migrateSecretMisconfigColumns();
        this.migrateAgentsAndNotificationsNodeId();
        this.migratePolicyEvaluationColumn();
        this.migrateNotificationCategory();
        this.migrateNotificationActor();
        this.migrateNotificationGitOpsDedupe();
        this.migrateMeshTables();
        this.migrateNodeLabels();
        this.migrateBlueprints();
        this.migrateAddNodeLastContact();
        this.migrateAddNodeCordonFields();
        this.migrateAddBlueprintPinnedNode();
        this.migrateAddBlueprintApproval();
        this.migrateAutoHealNodeId();
        this.migrateFleetSyncStickyError();
        this.migrateStackDossierHashes();
        this.migrateGitSourceMultiFile();
        this.migrateGitSourceSshDeployKey();
        this.migrateGitSourcePrivateCa();
        this.migrateGitSourceManifest();
        this.migrateGitSourceChangePlan();
        this.migrateGitOpsRecoveryColumns();
        this.migrateGitOpsCreateCheckpointSshDeployKey();
        this.migrateNodeUpdateSkips();
        this.migrateStackAlertServiceScope();

        // Reset the cache once at end of constructor in case any migration
        // populated it via getGlobalSettings() and a subsequent migration
        // changed the underlying rows.
        this.cachedGlobalSettings = null;
    }

    public static getInstance(): DatabaseService {
        if (!DatabaseService.instance) {
            DatabaseService.instance = new DatabaseService();
        }
        return DatabaseService.instance;
    }

    public getDb(): Database.Database {
        return this.db;
    }

    private initSchema() {
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS agents (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL DEFAULT 0,
        type TEXT NOT NULL,
        url TEXT NOT NULL,
        enabled INTEGER DEFAULT 0
      );

      CREATE TABLE IF NOT EXISTS global_settings (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
      );

      CREATE TABLE IF NOT EXISTS stack_alerts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        stack_name TEXT NOT NULL,
        service_name TEXT,
        metric TEXT NOT NULL,
        operator TEXT NOT NULL,
        threshold REAL NOT NULL,
        duration_mins INTEGER NOT NULL,
        cooldown_mins INTEGER NOT NULL,
        last_fired_at INTEGER DEFAULT 0
      );

      -- FK cascade is declarative only: PRAGMA foreign_keys is never enabled
      -- on this connection, so parent deletes must remove children explicitly
      -- (see deleteStackAlert).
      CREATE TABLE IF NOT EXISTS stack_alert_service_cooldowns (
        alert_id INTEGER NOT NULL,
        service_name TEXT NOT NULL,
        last_fired_at INTEGER NOT NULL,
        PRIMARY KEY (alert_id, service_name),
        FOREIGN KEY (alert_id) REFERENCES stack_alerts(id) ON DELETE CASCADE
      );

      CREATE TABLE IF NOT EXISTS notification_history (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL DEFAULT 0,
        level TEXT NOT NULL,
        message TEXT NOT NULL,
        timestamp INTEGER NOT NULL,
        is_read INTEGER DEFAULT 0
      );

      CREATE TABLE IF NOT EXISTS container_metrics (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        container_id TEXT NOT NULL,
        stack_name TEXT NOT NULL,
        cpu_percent REAL NOT NULL,
        memory_mb REAL NOT NULL,
        net_rx_mb REAL NOT NULL,
        net_tx_mb REAL NOT NULL,
        timestamp INTEGER NOT NULL
      );

      CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp);
      CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id);

      CREATE TABLE IF NOT EXISTS stack_update_status (
        node_id INTEGER NOT NULL DEFAULT 0,
        stack_name TEXT NOT NULL,
        has_update INTEGER DEFAULT 0,
        check_status TEXT NOT NULL DEFAULT 'ok',
        last_error TEXT,
        services_json TEXT,
        checked_at INTEGER NOT NULL,
        PRIMARY KEY (node_id, stack_name)
      );

      CREATE TABLE IF NOT EXISTS stack_scan_attempts (
        node_id INTEGER NOT NULL DEFAULT 0,
        stack_name TEXT NOT NULL,
        status TEXT NOT NULL,
        attempted_at INTEGER NOT NULL,
        error_message TEXT,
        PRIMARY KEY (node_id, stack_name)
      );

      CREATE TABLE IF NOT EXISTS nodes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL UNIQUE,
        type TEXT NOT NULL DEFAULT 'local',
        compose_dir TEXT NOT NULL DEFAULT '/app/compose',
        is_default INTEGER DEFAULT 0,
        status TEXT NOT NULL DEFAULT 'unknown',
        created_at INTEGER NOT NULL
      );

      CREATE TABLE IF NOT EXISTS pilot_enrollments (
        node_id INTEGER PRIMARY KEY,
        token_hash TEXT NOT NULL,
        expires_at INTEGER NOT NULL,
        used_at INTEGER,
        FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
      );

      CREATE TABLE IF NOT EXISTS system_state (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
      );

      CREATE TABLE IF NOT EXISTS webhooks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER,
        name TEXT NOT NULL,
        stack_name TEXT NOT NULL,
        action TEXT NOT NULL DEFAULT 'deploy',
        secret TEXT NOT NULL,
        enabled INTEGER DEFAULT 1,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
      );

      CREATE TABLE IF NOT EXISTS webhook_executions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        webhook_id INTEGER NOT NULL,
        action TEXT NOT NULL,
        status TEXT NOT NULL,
        trigger_source TEXT,
        duration_ms INTEGER,
        error TEXT,
        executed_at INTEGER NOT NULL,
        FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
      );

      CREATE INDEX IF NOT EXISTS idx_webhook_executions_webhook ON webhook_executions(webhook_id);

      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        username TEXT NOT NULL UNIQUE,
        password_hash TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'admin',
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
      );

      CREATE TABLE IF NOT EXISTS fleet_snapshots (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        description TEXT NOT NULL DEFAULT '',
        created_by TEXT NOT NULL,
        node_count INTEGER NOT NULL,
        stack_count INTEGER NOT NULL,
        skipped_nodes TEXT NOT NULL DEFAULT '[]',
        skipped_stacks TEXT NOT NULL DEFAULT '[]',
        documentation TEXT NOT NULL DEFAULT '',
        created_at INTEGER NOT NULL
      );

      CREATE TABLE IF NOT EXISTS fleet_snapshot_files (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        snapshot_id INTEGER NOT NULL,
        node_id INTEGER NOT NULL,
        node_name TEXT NOT NULL,
        stack_name TEXT NOT NULL,
        filename TEXT NOT NULL,
        content TEXT NOT NULL,
        FOREIGN KEY(snapshot_id) REFERENCES fleet_snapshots(id) ON DELETE CASCADE
      );

      CREATE INDEX IF NOT EXISTS idx_snapshot_files_snapshot ON fleet_snapshot_files(snapshot_id);
      CREATE INDEX IF NOT EXISTS idx_snapshot_files_node_stack ON fleet_snapshot_files(node_id, stack_name);

      CREATE TABLE IF NOT EXISTS audit_log (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        timestamp INTEGER NOT NULL,
        username TEXT NOT NULL,
        method TEXT NOT NULL,
        path TEXT NOT NULL,
        status_code INTEGER NOT NULL DEFAULT 0,
        node_id INTEGER,
        ip_address TEXT NOT NULL DEFAULT '',
        summary TEXT NOT NULL DEFAULT '',
        acting_as TEXT
      );

      CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
      CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username);

      CREATE TABLE IF NOT EXISTS console_session_jtis (
        jti TEXT PRIMARY KEY,
        used_at INTEGER NOT NULL,
        expires_at INTEGER NOT NULL
      );
      CREATE INDEX IF NOT EXISTS idx_console_session_jtis_expires ON console_session_jtis(expires_at);

      CREATE TABLE IF NOT EXISTS api_tokens (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        token_hash TEXT NOT NULL UNIQUE,
        name TEXT NOT NULL,
        scope TEXT NOT NULL DEFAULT 'read-only',
        user_id INTEGER NOT NULL,
        created_at INTEGER NOT NULL,
        last_used_at INTEGER,
        expires_at INTEGER,
        revoked_at INTEGER,
        FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
      );

      CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
      CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);

      CREATE TABLE IF NOT EXISTS scheduled_tasks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        target_type TEXT NOT NULL,
        target_id TEXT,
        node_id INTEGER,
        action TEXT NOT NULL,
        cron_expression TEXT NOT NULL,
        enabled INTEGER DEFAULT 1,
        created_by TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL,
        last_run_at INTEGER,
        next_run_at INTEGER,
        last_status TEXT,
        last_error TEXT
      );

      CREATE TABLE IF NOT EXISTS scheduled_task_runs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        task_id INTEGER NOT NULL,
        started_at INTEGER NOT NULL,
        completed_at INTEGER,
        status TEXT NOT NULL DEFAULT 'running',
        output TEXT,
        error TEXT,
        FOREIGN KEY(task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE
      );

      CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task ON scheduled_task_runs(task_id);
      CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_status ON scheduled_task_runs(status);
      CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at);

      CREATE TABLE IF NOT EXISTS vulnerability_scans (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL,
        image_ref TEXT NOT NULL,
        image_digest TEXT,
        scanned_at INTEGER NOT NULL,
        total_vulnerabilities INTEGER NOT NULL DEFAULT 0,
        critical_count INTEGER NOT NULL DEFAULT 0,
        high_count INTEGER NOT NULL DEFAULT 0,
        medium_count INTEGER NOT NULL DEFAULT 0,
        low_count INTEGER NOT NULL DEFAULT 0,
        unknown_count INTEGER NOT NULL DEFAULT 0,
        fixable_count INTEGER NOT NULL DEFAULT 0,
        secret_count INTEGER NOT NULL DEFAULT 0,
        misconfig_count INTEGER NOT NULL DEFAULT 0,
        scanners_used TEXT NOT NULL DEFAULT 'vuln',
        highest_severity TEXT,
        os_info TEXT,
        trivy_version TEXT,
        scan_duration_ms INTEGER,
        triggered_by TEXT NOT NULL DEFAULT 'manual',
        status TEXT NOT NULL DEFAULT 'completed',
        error TEXT,
        stack_context TEXT
      );

      CREATE INDEX IF NOT EXISTS idx_vuln_scans_node_image ON vulnerability_scans(node_id, image_ref);
      CREATE INDEX IF NOT EXISTS idx_vuln_scans_digest ON vulnerability_scans(image_digest);
      CREATE INDEX IF NOT EXISTS idx_vuln_scans_scanned_at ON vulnerability_scans(scanned_at);
      CREATE INDEX IF NOT EXISTS idx_vuln_scans_status ON vulnerability_scans(status);

      CREATE TABLE IF NOT EXISTS vulnerability_details (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        scan_id INTEGER NOT NULL,
        vulnerability_id TEXT NOT NULL,
        pkg_name TEXT NOT NULL,
        installed_version TEXT NOT NULL,
        fixed_version TEXT,
        severity TEXT NOT NULL,
        title TEXT,
        description TEXT,
        primary_url TEXT,
        FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
      );

      CREATE INDEX IF NOT EXISTS idx_vuln_details_scan ON vulnerability_details(scan_id);
      CREATE INDEX IF NOT EXISTS idx_vuln_details_severity ON vulnerability_details(severity);

      CREATE TABLE IF NOT EXISTS secret_findings (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        scan_id INTEGER NOT NULL,
        rule_id TEXT NOT NULL,
        category TEXT,
        severity TEXT NOT NULL,
        title TEXT,
        target TEXT NOT NULL,
        start_line INTEGER,
        end_line INTEGER,
        match_excerpt TEXT,
        FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
      );
      CREATE INDEX IF NOT EXISTS idx_secret_findings_scan ON secret_findings(scan_id);

      CREATE TABLE IF NOT EXISTS misconfig_findings (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        scan_id INTEGER NOT NULL,
        rule_id TEXT NOT NULL,
        check_id TEXT,
        severity TEXT NOT NULL,
        title TEXT,
        message TEXT,
        resolution TEXT,
        target TEXT NOT NULL,
        primary_url TEXT,
        FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
      );
      CREATE INDEX IF NOT EXISTS idx_misconfig_findings_scan ON misconfig_findings(scan_id);

      CREATE TABLE IF NOT EXISTS scan_policies (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        node_id INTEGER,
        stack_pattern TEXT,
        max_severity TEXT NOT NULL DEFAULT 'CRITICAL',
        block_on_deploy INTEGER NOT NULL DEFAULT 0,
        enabled INTEGER NOT NULL DEFAULT 1,
        block_on_severity INTEGER NOT NULL DEFAULT 1,
        block_on_kev INTEGER NOT NULL DEFAULT 0,
        block_on_fixable INTEGER NOT NULL DEFAULT 0,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
      );

      CREATE TABLE IF NOT EXISTS fleet_sync_status (
        node_id INTEGER NOT NULL,
        resource TEXT NOT NULL,
        last_success_at INTEGER,
        last_failure_at INTEGER,
        last_error TEXT,
        PRIMARY KEY (node_id, resource)
      );

      CREATE TABLE IF NOT EXISTS cve_suppressions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        cve_id TEXT NOT NULL,
        pkg_name TEXT,
        image_pattern TEXT,
        reason TEXT NOT NULL DEFAULT '',
        created_by TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        expires_at INTEGER,
        replicated_from_control INTEGER NOT NULL DEFAULT 0
      );
      CREATE INDEX IF NOT EXISTS idx_cve_suppressions_cve ON cve_suppressions(cve_id);
      CREATE INDEX IF NOT EXISTS idx_cve_suppressions_expires ON cve_suppressions(expires_at);
      -- COALESCE makes NULL scope slots collide the way users expect (NULL == NULL here).
      CREATE UNIQUE INDEX IF NOT EXISTS idx_cve_suppressions_unique
        ON cve_suppressions(cve_id, COALESCE(pkg_name, ''), COALESCE(image_pattern, ''));

      CREATE TABLE IF NOT EXISTS misconfig_acknowledgements (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        rule_id TEXT NOT NULL,
        stack_pattern TEXT,
        reason TEXT NOT NULL DEFAULT '',
        created_by TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        expires_at INTEGER,
        replicated_from_control INTEGER NOT NULL DEFAULT 0
      );
      CREATE INDEX IF NOT EXISTS idx_misconfig_ack_rule ON misconfig_acknowledgements(rule_id);
      CREATE INDEX IF NOT EXISTS idx_misconfig_ack_expires ON misconfig_acknowledgements(expires_at);
      CREATE UNIQUE INDEX IF NOT EXISTS idx_misconfig_ack_unique
        ON misconfig_acknowledgements(rule_id, COALESCE(stack_pattern, ''));

      -- Time-varying exploit intelligence (CISA KEV + FIRST EPSS), refreshed by
      -- CveIntelService and joined to findings at read time by CVE id. Never
      -- frozen onto vulnerability_details, so a CVE entering KEV later lights up
      -- on scans already stored.
      CREATE TABLE IF NOT EXISTS cve_intel (
        cve_id TEXT PRIMARY KEY,
        kev INTEGER NOT NULL DEFAULT 0,
        kev_date TEXT,
        epss_score REAL,
        epss_percentile REAL,
        updated_at INTEGER NOT NULL
      );
      CREATE INDEX IF NOT EXISTS idx_cve_intel_kev ON cve_intel(kev);

      CREATE TABLE IF NOT EXISTS stack_labels (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL DEFAULT 0,
        name TEXT NOT NULL,
        color TEXT NOT NULL,
        UNIQUE(node_id, name)
      );

      CREATE TABLE IF NOT EXISTS stack_label_assignments (
        label_id INTEGER NOT NULL REFERENCES stack_labels(id) ON DELETE CASCADE,
        stack_name TEXT NOT NULL,
        node_id INTEGER NOT NULL DEFAULT 0,
        PRIMARY KEY (label_id, stack_name, node_id)
      );

      CREATE INDEX IF NOT EXISTS idx_label_assignments_stack
        ON stack_label_assignments(stack_name, node_id);

      CREATE TABLE IF NOT EXISTS user_mfa (
        user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
        enabled INTEGER NOT NULL DEFAULT 0,
        totp_secret_encrypted TEXT,
        backup_codes_json TEXT,
        sso_enforce_mfa INTEGER NOT NULL DEFAULT 0,
        failed_attempts INTEGER NOT NULL DEFAULT 0,
        locked_until INTEGER,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
      );

      CREATE TABLE IF NOT EXISTS mfa_used_tokens (
        user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        code TEXT NOT NULL,
        window INTEGER NOT NULL,
        used_at INTEGER NOT NULL,
        PRIMARY KEY (user_id, code, window)
      );

      CREATE INDEX IF NOT EXISTS idx_mfa_used_tokens_used_at ON mfa_used_tokens(used_at);

      CREATE TABLE IF NOT EXISTS stack_git_sources (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        stack_name TEXT NOT NULL UNIQUE,
        repo_url TEXT NOT NULL,
        branch TEXT NOT NULL,
        compose_path TEXT NOT NULL,
        compose_paths TEXT,
        context_dir TEXT,
        sync_env INTEGER NOT NULL DEFAULT 0,
        env_path TEXT,
        auth_type TEXT NOT NULL DEFAULT 'none',
        encrypted_token TEXT,
        auto_apply_on_webhook INTEGER NOT NULL DEFAULT 0,
        auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0,
        last_applied_commit_sha TEXT,
        last_applied_content_hash TEXT,
        pending_commit_sha TEXT,
        pending_compose_content TEXT,
        pending_env_content TEXT,
        pending_fetched_at INTEGER,
        last_debounce_at INTEGER,
        applied_deploy_spec TEXT,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
      );

      CREATE TABLE IF NOT EXISTS auto_heal_policies (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL DEFAULT 1,
        proxy_entitled_until INTEGER NOT NULL DEFAULT 0,
        stack_name TEXT NOT NULL,
        service_name TEXT,
        unhealthy_duration_mins INTEGER NOT NULL,
        cooldown_mins INTEGER NOT NULL DEFAULT 5,
        max_restarts_per_hour INTEGER NOT NULL DEFAULT 3,
        auto_disable_after_failures INTEGER NOT NULL DEFAULT 5,
        enabled INTEGER NOT NULL DEFAULT 1,
        consecutive_failures INTEGER NOT NULL DEFAULT 0,
        last_fired_at INTEGER NOT NULL DEFAULT 0,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
      );

      CREATE TABLE IF NOT EXISTS auto_heal_history (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        policy_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        service_name TEXT,
        container_name TEXT NOT NULL,
        container_id TEXT NOT NULL,
        action TEXT NOT NULL,
        reason TEXT NOT NULL,
        success INTEGER NOT NULL,
        error TEXT,
        timestamp INTEGER NOT NULL
      );

      CREATE INDEX IF NOT EXISTS idx_auto_heal_history_policy_ts
        ON auto_heal_history(policy_id, timestamp DESC);

      CREATE TABLE IF NOT EXISTS stack_dossiers (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        purpose TEXT NOT NULL DEFAULT '',
        owner TEXT NOT NULL DEFAULT '',
        access_urls TEXT NOT NULL DEFAULT '',
        static_ip TEXT NOT NULL DEFAULT '',
        vlan TEXT NOT NULL DEFAULT '',
        firewall_notes TEXT NOT NULL DEFAULT '',
        reverse_proxy_notes TEXT NOT NULL DEFAULT '',
        backup_notes TEXT NOT NULL DEFAULT '',
        upgrade_notes TEXT NOT NULL DEFAULT '',
        recovery_notes TEXT NOT NULL DEFAULT '',
        custom_notes TEXT NOT NULL DEFAULT '',
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL,
        last_drift_check_at INTEGER,
        UNIQUE(node_id, stack_name)
      );

      CREATE TABLE IF NOT EXISTS stack_drift_findings (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        service TEXT NOT NULL,
        finding_type TEXT NOT NULL,
        severity TEXT NOT NULL DEFAULT 'warning',
        message TEXT NOT NULL,
        expected_json TEXT,
        actual_json TEXT,
        detected_at INTEGER NOT NULL,
        resolved_at INTEGER
      );
      CREATE INDEX IF NOT EXISTS idx_stack_drift_findings_open
        ON stack_drift_findings(node_id, stack_name, resolved_at);

      CREATE TABLE IF NOT EXISTS stack_exposure_intent (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        service TEXT NOT NULL DEFAULT '',
        intent TEXT NOT NULL CHECK(intent IN (${EXPOSURE_INTENTS.map(i => `'${i}'`).join(', ')})),
        updated_at INTEGER NOT NULL,
        updated_by TEXT,
        UNIQUE(node_id, stack_name, service)
      );
      CREATE INDEX IF NOT EXISTS idx_stack_exposure_intent_stack
        ON stack_exposure_intent(node_id, stack_name);

      CREATE TABLE IF NOT EXISTS preflight_runs (
        id TEXT PRIMARY KEY,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        source_hash TEXT,
        rendered_hash TEXT,
        status TEXT NOT NULL CHECK (status IN ('pass','unrenderable','blocker','high','warning','info')),
        highest_severity TEXT CHECK (highest_severity IN ('blocker','high','warning','info')),
        created_at INTEGER NOT NULL,
        created_by TEXT
      );
      CREATE INDEX IF NOT EXISTS idx_preflight_runs_node_stack
        ON preflight_runs(node_id, stack_name);

      CREATE TABLE IF NOT EXISTS preflight_findings (
        id TEXT PRIMARY KEY,
        run_id TEXT NOT NULL,
        rule_id TEXT NOT NULL,
        severity TEXT NOT NULL CHECK (severity IN ('blocker','high','warning','info')),
        title TEXT NOT NULL,
        message TEXT NOT NULL,
        source_path TEXT,
        remediation TEXT,
        service TEXT,
        created_at INTEGER NOT NULL
      );
      CREATE INDEX IF NOT EXISTS idx_preflight_findings_run
        ON preflight_findings(run_id);

      CREATE TABLE IF NOT EXISTS preflight_acknowledgements (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        rule_id TEXT NOT NULL,
        service TEXT,
        reason TEXT NOT NULL DEFAULT '',
        expiry_mode TEXT NOT NULL DEFAULT 'forever'
          CHECK (expiry_mode IN ('forever','until_compose_change','days','until_image_change')),
        expires_at INTEGER,
        anchor_rendered_hash TEXT,
        anchor_image_ref TEXT,
        created_by TEXT NOT NULL,
        created_at INTEGER NOT NULL
      );
      CREATE UNIQUE INDEX IF NOT EXISTS idx_preflight_ack_unique
        ON preflight_acknowledgements(node_id, stack_name, rule_id, COALESCE(service,''));
      CREATE INDEX IF NOT EXISTS idx_preflight_ack_stack
        ON preflight_acknowledgements(node_id, stack_name);

      CREATE TABLE IF NOT EXISTS stack_exposure (
        node_id INTEGER NOT NULL DEFAULT 0,
        stack_name TEXT NOT NULL,
        descriptor TEXT NOT NULL,
        computed_at INTEGER NOT NULL,
        PRIMARY KEY (node_id, stack_name)
      );

      CREATE TABLE IF NOT EXISTS health_gate_runs (
        id TEXT PRIMARY KEY,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        trigger_action TEXT NOT NULL CHECK (trigger_action IN ('update','deploy','service_update','service_restore','recovery')),
        status TEXT NOT NULL CHECK (status IN ('observing','passed','failed','unknown')),
        reason TEXT,
        window_seconds INTEGER NOT NULL,
        containers_json TEXT NOT NULL DEFAULT '[]',
        started_at INTEGER NOT NULL,
        ended_at INTEGER,
        created_by TEXT,
        target_scope TEXT NOT NULL DEFAULT 'stack' CHECK (target_scope IN ('stack','service')),
        service_name TEXT,
        failure_source TEXT CHECK (failure_source IS NULL OR failure_source IN ('primary','collateral'))
      );
      CREATE INDEX IF NOT EXISTS idx_health_gate_runs_node_stack
        ON health_gate_runs(node_id, stack_name, started_at);

      CREATE TABLE IF NOT EXISTS service_update_recovery (
        id TEXT PRIMARY KEY,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        service_name TEXT NOT NULL,
        replicas_json TEXT NOT NULL,
        majority_image_id TEXT NOT NULL,
        declared_image_ref TEXT NOT NULL,
        weak_floating_tag INTEGER NOT NULL DEFAULT 0,
        health_gate_id TEXT,
        status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','restoring','consumed','expired','invalidated')),
        expires_at INTEGER NOT NULL,
        claim_expires_at INTEGER,
        created_at INTEGER NOT NULL,
        created_by TEXT
      );
      CREATE INDEX IF NOT EXISTS idx_service_update_recovery_node_stack_service
        ON service_update_recovery(node_id, stack_name, service_name, status);
      CREATE INDEX IF NOT EXISTS idx_service_update_recovery_status_expires
        ON service_update_recovery(status, expires_at);
      CREATE INDEX IF NOT EXISTS idx_service_update_recovery_status_claim_expires
        ON service_update_recovery(status, claim_expires_at);


      CREATE TABLE IF NOT EXISTS stack_update_recovery_generations (
        id TEXT PRIMARY KEY,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        status TEXT NOT NULL CHECK (status IN ('candidate','active','restored_current','superseded','abandoned','recovery_required')),
        phase TEXT NOT NULL CHECK (phase IN ('captured','acquired','handoff_committed','reconciling','immediate_verified')),
        is_current INTEGER NOT NULL DEFAULT 0,
        backup_slot_id TEXT,
        override_path TEXT,
        services_json TEXT NOT NULL,
        health_gate_id TEXT,
        gate_retain_until INTEGER,
        artifact_expires_at INTEGER,
        operation_lease_expires_at INTEGER,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL,
        created_by TEXT,
        artifacts_retired INTEGER NOT NULL DEFAULT 0
      );
      CREATE INDEX IF NOT EXISTS idx_stack_update_recovery_node_stack_current
        ON stack_update_recovery_generations(node_id, stack_name, is_current);
      CREATE INDEX IF NOT EXISTS idx_stack_update_recovery_status_expires
        ON stack_update_recovery_generations(status, artifact_expires_at);
      CREATE INDEX IF NOT EXISTS idx_stack_update_recovery_phase_lease
        ON stack_update_recovery_generations(phase, operation_lease_expires_at);

      CREATE TABLE IF NOT EXISTS stack_update_cleanup_pending (
        id TEXT PRIMARY KEY,
        node_id INTEGER,
        stack_name TEXT,
        status TEXT NOT NULL CHECK (status IN ('prepared','ready','cancelled')),
        target_kind TEXT NOT NULL CHECK (target_kind IN ('local_socket')),
        rollback_tags_json TEXT NOT NULL,
        override_paths_json TEXT NOT NULL,
        prune_volumes_requested INTEGER NOT NULL DEFAULT 0,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
      );
      CREATE INDEX IF NOT EXISTS idx_stack_update_cleanup_status
        ON stack_update_cleanup_pending(status);
      CREATE INDEX IF NOT EXISTS idx_stack_update_cleanup_node_stack
        ON stack_update_cleanup_pending(node_id, stack_name);

      CREATE TABLE IF NOT EXISTS secrets (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL UNIQUE,
        description TEXT NOT NULL DEFAULT '',
        current_version INTEGER NOT NULL DEFAULT 1,
        created_at INTEGER NOT NULL,
        created_by TEXT NOT NULL,
        updated_at INTEGER NOT NULL
      );
      CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name);

      CREATE TABLE IF NOT EXISTS secret_versions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        secret_id INTEGER NOT NULL,
        version INTEGER NOT NULL,
        encrypted_payload TEXT NOT NULL,
        key_count INTEGER NOT NULL,
        created_at INTEGER NOT NULL,
        created_by TEXT NOT NULL,
        note TEXT NOT NULL DEFAULT '',
        UNIQUE(secret_id, version),
        FOREIGN KEY(secret_id) REFERENCES secrets(id) ON DELETE CASCADE
      );
      CREATE INDEX IF NOT EXISTS idx_secret_versions_secret ON secret_versions(secret_id);

      CREATE TABLE IF NOT EXISTS secret_pushes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        secret_id INTEGER NOT NULL,
        version INTEGER NOT NULL,
        push_id TEXT NOT NULL,
        node_id INTEGER NOT NULL,
        stack_name TEXT NOT NULL,
        env_file_basename TEXT NOT NULL DEFAULT '.env',
        status TEXT NOT NULL,
        error TEXT NOT NULL DEFAULT '',
        added_count INTEGER NOT NULL DEFAULT 0,
        changed_count INTEGER NOT NULL DEFAULT 0,
        unchanged_count INTEGER NOT NULL DEFAULT 0,
        pushed_by TEXT NOT NULL,
        pushed_at INTEGER NOT NULL,
        FOREIGN KEY(secret_id) REFERENCES secrets(id) ON DELETE CASCADE
      );
      CREATE INDEX IF NOT EXISTS idx_secret_pushes_push ON secret_pushes(push_id);
      CREATE INDEX IF NOT EXISTS idx_secret_pushes_secret_version ON secret_pushes(secret_id, version);
      CREATE INDEX IF NOT EXISTS idx_secret_pushes_node ON secret_pushes(node_id, stack_name);
    `);

        this.db.exec(GITOPS_SCHEMA_SQL);

        // Apply migrations safely (ignore if columns already exist)
        const maybeAddCol = (table: string, col: string, def: string) => {
            try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
        };

        // Remote Host Console bridges record the hub operator separately from
        // the console_session principal (username stays console_session).
        maybeAddCol('audit_log', 'acting_as', 'TEXT');
        // Cached INSERT may predate the column; rebuild on next flush.
        this.auditLogInsertStmt = null;

        this.db.exec(`
          CREATE TABLE IF NOT EXISTS console_session_jtis (
            jti TEXT PRIMARY KEY,
            used_at INTEGER NOT NULL,
            expires_at INTEGER NOT NULL
          );
          CREATE INDEX IF NOT EXISTS idx_console_session_jtis_expires ON console_session_jtis(expires_at);
        `);

        maybeAddCol('stack_update_recovery_generations', 'artifacts_retired', 'INTEGER NOT NULL DEFAULT 0');
        // Manual release (operator gave up rollback protection early). Additive
        // columns rather than a new `status` enum value, since `status` carries a
        // CHECK constraint that would need the heavier table-rebuild migration
        // pattern used for health_gate_runs below.
        maybeAddCol('stack_update_recovery_generations', 'released_at', 'INTEGER');
        maybeAddCol('stack_update_recovery_generations', 'released_by', 'TEXT');
        // Authored-project generation content key + capture trigger kind.
        maybeAddCol('stack_update_recovery_generations', 'content_path', 'TEXT');
        maybeAddCol('stack_update_recovery_generations', 'operation_kind', 'TEXT');
        maybeAddCol('stack_update_cleanup_pending', 'required_blueprint_id', 'INTEGER');
        // Resolved ref kind for existing GitOps generations. New installs get it
        // from the CREATE TABLE; older DBs need the additive column here.
        maybeAddCol('gitops_generations', 'resolved_ref_kind', 'TEXT NULL');
        maybeAddCol('gitops_applications', 'fetched_resolved_ref_kind', 'TEXT NULL');
        // Source suspension reason, distinct from the rollout pause_reason
        // existing installs already have. New installs get it from the
        // CREATE TABLE; older DBs need the additive column here.
        maybeAddCol('gitops_applications', 'source_suspended_reason', 'TEXT NULL');

        // Distributed API model columns
        maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
        maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
        maybeAddCol('webhooks', 'node_id', 'INTEGER');
        this.db.prepare(`
          UPDATE webhooks
          SET node_id = COALESCE((SELECT id FROM nodes WHERE is_default = 1 LIMIT 1), 1)
          WHERE node_id IS NULL
        `).run();

        // Pilot Agent outbound-mode columns
        maybeAddCol('nodes', 'mode', "TEXT NOT NULL DEFAULT 'proxy'");
        maybeAddCol('nodes', 'pilot_last_seen', 'INTEGER');
        maybeAddCol('nodes', 'pilot_agent_version', 'TEXT');

        // Fleet snapshot per-stack capture warnings (partial-capture surfacing)
        maybeAddCol('fleet_snapshots', 'skipped_stacks', "TEXT NOT NULL DEFAULT '[]'");
        // Captured Stack Dossier metadata (opt-in documentation snapshots)
        maybeAddCol('fleet_snapshots', 'documentation', "TEXT NOT NULL DEFAULT ''");

        // Scan finding enrichment: scan-intrinsic fields Trivy returns that the
        // triage/action posture surfaces (status, CVSS, vendor severity, purl,
        // package path, layer). Nullable; older rows simply have no enrichment.
        maybeAddCol('vulnerability_details', 'status', 'TEXT');
        maybeAddCol('vulnerability_details', 'cvss_score', 'REAL');
        maybeAddCol('vulnerability_details', 'cvss_vector', 'TEXT');
        maybeAddCol('vulnerability_details', 'cvss_source', 'TEXT');
        maybeAddCol('vulnerability_details', 'vendor_severity', 'TEXT');
        maybeAddCol('vulnerability_details', 'purl', 'TEXT');
        maybeAddCol('vulnerability_details', 'pkg_path', 'TEXT');
        maybeAddCol('vulnerability_details', 'layer_digest', 'TEXT');

        // Triage decisions layered on CVE suppressions (status + optional OpenVEX
        // justification). Existing rows default to 'accepted' (the prior behavior).
        maybeAddCol('cve_suppressions', 'status', "TEXT NOT NULL DEFAULT 'accepted'");
        maybeAddCol('cve_suppressions', 'justification', 'TEXT');

        maybeAddCol('preflight_runs', 'service_images', 'TEXT');

        // Scheduled operations migrations
        maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
        maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
        maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
        maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
        maybeAddCol('scheduled_tasks', 'selector_type', 'TEXT DEFAULT NULL');
        maybeAddCol('scheduled_tasks', 'selector_value', 'TEXT DEFAULT NULL');
        maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0');
        maybeAddCol('scheduled_tasks', 'run_at', 'INTEGER DEFAULT NULL');
        maybeAddCol('scheduled_tasks', 'creator_user_id', 'INTEGER DEFAULT NULL');

        // Backfill creator_user_id from the created_by username column.
        // Rows whose username no longer matches a user stay NULL (legacy,
        // unrevalidated path — they were created under the old requireAdmin gate).
        this.db.exec(`
          UPDATE scheduled_tasks
          SET creator_user_id = (
            SELECT id FROM users WHERE username = scheduled_tasks.created_by
          )
          WHERE creator_user_id IS NULL
        `);

        // Recreate stack_update_status with composite PK (node_id, stack_name).
        // Original table had stack_name as sole PK which breaks when multiple nodes share stack names.
        const susInfo = this.db.pragma('table_info(stack_update_status)') as Array<{ name: string; pk: number }>;
        const needsRecreate = susInfo.some(c => c.name === 'stack_name' && c.pk === 1) && !susInfo.some(c => c.name === 'node_id' && c.pk > 0);
        if (needsRecreate) {
          this.db.exec(`
            CREATE TABLE stack_update_status_new (
              node_id INTEGER NOT NULL DEFAULT 0,
              stack_name TEXT NOT NULL,
              has_update INTEGER DEFAULT 0,
              checked_at INTEGER NOT NULL,
              PRIMARY KEY (node_id, stack_name)
            );
            INSERT OR IGNORE INTO stack_update_status_new (node_id, stack_name, has_update, checked_at)
              SELECT COALESCE(node_id, 0), stack_name, has_update, checked_at FROM stack_update_status;
            DROP TABLE stack_update_status;
            ALTER TABLE stack_update_status_new RENAME TO stack_update_status;
          `);
        }

        // Tri-state image-update check outcome. Must run AFTER the composite-PK
        // recreate above (that block recreates the table from the original four
        // columns, so columns added earlier would be dropped). 'ok' = every
        // checkable image was reached; the detector records 'failed'/'partial'
        // plus a reason when registry checks could not determine status, so a
        // failed check is no longer indistinguishable from "up to date".
        maybeAddCol('stack_update_status', 'check_status', "TEXT NOT NULL DEFAULT 'ok'");
        maybeAddCol('stack_update_status', 'last_error', 'TEXT');
        // Per-service image-update snapshot. Must run AFTER the composite-PK
        // recreate above so it is never silently dropped on old installs.
        maybeAddCol('stack_update_status', 'services_json', 'TEXT');

        this.migrateHealthGateTargetSchema();

        // Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written)
        const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];
        for (const col of legacyCols) {
            try { this.db.prepare(`ALTER TABLE nodes DROP COLUMN ${col}`).run(); } catch (e: unknown) {
                // Expected: column already dropped or never existed
                if (!String((e as Error)?.message).includes('no such column')) {
                    console.warn(`[DatabaseService] Unexpected error dropping legacy column "${col}":`, (e as Error).message);
                }
            }
        }

        // Initialize default global settings if they don't exist
        const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
        stmt.run('host_cpu_limit', '90');
        stmt.run('host_ram_limit', '90');
        stmt.run('host_disk_limit', '90');
        stmt.run('host_alert_suppression_mins', '60');
        stmt.run('host_alerts_enabled', '0');
        stmt.run('global_crash', '1');
        stmt.run('docker_janitor_gb', '5');
        stmt.run('developer_mode', '0');
        stmt.run('metrics_retention_hours', '24');
        stmt.run('log_retention_days', '30');
        stmt.run('scan_history_per_image_limit', '50');
        // Remove scan results when their image is gone from Docker or their
        // stack folder is deleted, so the Security Overview reflects what still
        // exists. On by default; operators who keep scan history for deleted
        // artifacts can turn it off.
        stmt.run('prune_orphaned_scans', '1');
        stmt.run('trivy_auto_update', '0');
        stmt.run('trivy_last_notified_version', '');
        stmt.run('deploy_block_honor_suppressions', '0');
        stmt.run('pre_deploy_scan_advisory', '0');
        // Outbound CVE exploit-intel (KEV + EPSS) fetch. On by default (a safe
        // convenience that degrades gracefully offline); operators on air-gapped
        // or firewalled hosts can turn it off.
        stmt.run('cve_intel_enabled', '1');
        stmt.run('mesh_auto_recreate', '0');
        stmt.run('prune_on_update', '1');
        // Managed by /api/sso/auth-mode, not the generic /api/settings route
        // (activation needs safety validation).
        stmt.run('authentication_mode', 'local_and_sso');
        stmt.run('reclaim_hero', '0');
        stmt.run('health_gate_enabled', '1');
        stmt.run('health_gate_window_seconds', '90');
        // Superseded-generation retention (days) and a per-stack cap on total
        // retained generations (0 = unlimited). Never applies to the current
        // generation, which stays protected until superseded or released.
        stmt.run('recovery_retention_days', '7');
        stmt.run('recovery_max_generations', '0');
        stmt.run('image_update_check_interval_minutes', '120');
        stmt.run('image_update_check_mode', 'interval');
        stmt.run('image_update_check_cron', '');
        stmt.run('image_update_sidebar_indicators', '1');
        // Opt-out for background registry polling. Default on so upgrades keep
        // current behavior; missing key is also treated as enabled at read time.
        stmt.run('image_update_checks_enabled', '1');
        stmt.run('notification_dispatch_retries', '0');
        stmt.run('env_block_deploy_on_missing_required', '0');
        stmt.run('auto_create_missing_external_networks', '0');
        // Silently extend an actively-used session's cookie instead of hard
        // expiring it. On by default (matches how most session-based web apps
        // behave); admins who want a strict absolute session ceiling can turn
        // it off in Settings > Users.
        stmt.run('session_sliding_refresh', '1');
stmt.run('gitops_schema_version', '1');
        // SSO role sync defaults off: admin-set roles persist across SSO sign-ins;
        // operators who want IdP group membership to drive roles opt in via Settings > SSO.
        stmt.run('sso_role_sync', '0');

        // Seed the default local node if none exists
        const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
        if (nodeCount === 0) {
            this.db.prepare(
                'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
            ).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
        }

        this.logLocalNodeWarnings();
    }

    /** Count nodes with type='local'. */
    public getLocalNodeCount(): number {
        const row = this.db.prepare("SELECT COUNT(*) as count FROM nodes WHERE type = 'local'").get() as { count: number };
        return row.count;
    }

    /**
     * Log warnings when the local-node count is not exactly one. Extracted as a
     * public method so tests can drive it against known database states without
     * re-running the full schema migration.
     */
    public logLocalNodeWarnings(): void {
        const count = this.getLocalNodeCount();
        if (count > 1) {
            console.warn(
                `[Startup] Found ${count} local nodes (expected 1). ` +
                'Extra local nodes can be removed in Settings → Nodes. ' +
                'Deleting a local node removes its schedules, labels, dossiers, ' +
                'and other node-scoped data; containers on the host are not affected.'
            );
        } else if (count === 0) {
            console.warn(
                '[Startup] No local node found. ' +
                'Create one in Settings → Nodes to manage this instance\'s Docker engine.'
            );
        }
    }

    private migrateAdminToUsersTable(): void {
        const userCount = (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
        if (userCount > 0) return;

        const settings = this.getGlobalSettings();
        const username = settings.auth_username;
        const passwordHash = settings.auth_password_hash;
        if (!username || !passwordHash) return;

        const now = Date.now();
        this.db.prepare(
            'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
        ).run(username, passwordHash, 'admin', now, now);
        console.log('Migrated legacy admin user to users table.');
    }

    private migrateJsonConfig(dataDir: string) {
        const configPath = path.join(dataDir, 'sencho.json');
        if (fs.existsSync(configPath)) {
            try {
                const data = fs.readFileSync(configPath, 'utf-8');
                const config = JSON.parse(data);

                if (config.username && config.passwordHash && config.jwtSecret) {
                    const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
                    stmt.run('auth_username', config.username);
                    stmt.run('auth_password_hash', config.passwordHash);
                    stmt.run('auth_jwt_secret', config.jwtSecret);

                    console.log('Successfully migrated sencho.json credentials to SQLite global_settings.');
                    fs.unlinkSync(configPath);
                }
            } catch (err) {
                console.error('Failed to migrate sencho.json:', err);
            }
        }
    }

    /**
     * Rebuild health_gate_runs when the CHECK lacks service_update,
     * service_restore, or recovery, or when target/failure columns are missing.
     * After the CHECK is current, ensure deployed_generation_id exists.
     * Idempotent and restart-safe: drops a stale temporary table, then rebuilds
     * in one better-sqlite3 transaction so an interrupted startup cannot leave
     * CREATE TABLE health_gate_runs_new blocking the next boot.
     */
    private migrateHealthGateTargetSchema(): void {
        const tableSql = (this.db.prepare(
            "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'health_gate_runs'"
        ).get() as { sql: string } | undefined)?.sql ?? '';
        const cols = this.db.pragma('table_info(health_gate_runs)') as Array<{ name: string }>;
        const colNames = new Set(cols.map(c => c.name));
        const hasTarget = colNames.has('target_scope');
        const hasFailure = colNames.has('failure_source');
        const hasWideTrigger = tableSql.includes('service_update') && tableSql.includes('service_restore');
        const hasRecoveryTrigger = tableSql.includes("'recovery'");
        if (hasTarget && hasFailure && hasWideTrigger && hasRecoveryTrigger) {
            this.ensureHealthGateDeployedGenerationColumn();
            return;
        }

        // A previous crash between CREATE and RENAME leaves this temp table behind.
        this.db.exec('DROP TABLE IF EXISTS health_gate_runs_new');

        const targetExpr = hasTarget ? 'target_scope' : "'stack'";
        const serviceExpr = colNames.has('service_name') ? 'service_name' : 'NULL';
        const failureExpr = hasFailure ? 'failure_source' : 'NULL';
        const deployedExpr = colNames.has('deployed_generation_id') ? 'deployed_generation_id' : 'NULL';

        this.db.transaction(() => {
            this.db.exec(`
                CREATE TABLE health_gate_runs_new (
                  id TEXT PRIMARY KEY,
                  node_id INTEGER NOT NULL,
                  stack_name TEXT NOT NULL,
                  trigger_action TEXT NOT NULL CHECK (trigger_action IN ('update','deploy','service_update','service_restore','recovery')),
                  status TEXT NOT NULL CHECK (status IN ('observing','passed','failed','unknown')),
                  reason TEXT,
                  window_seconds INTEGER NOT NULL,
                  containers_json TEXT NOT NULL DEFAULT '[]',
                  started_at INTEGER NOT NULL,
                  ended_at INTEGER,
                  created_by TEXT,
                  target_scope TEXT NOT NULL DEFAULT 'stack' CHECK (target_scope IN ('stack','service')),
                  service_name TEXT,
                  failure_source TEXT CHECK (failure_source IS NULL OR failure_source IN ('primary','collateral')),
                  deployed_generation_id TEXT NULL
                );
                INSERT INTO health_gate_runs_new (
                  id, node_id, stack_name, trigger_action, status, reason, window_seconds,
                  containers_json, started_at, ended_at, created_by, target_scope, service_name,
                  failure_source, deployed_generation_id
                )
                SELECT
                  id, node_id, stack_name, trigger_action, status, reason, window_seconds,
                  containers_json, started_at, ended_at, created_by,
                  ${targetExpr}, ${serviceExpr}, ${failureExpr}, ${deployedExpr}
                FROM health_gate_runs;
                DROP TABLE health_gate_runs;
                ALTER TABLE health_gate_runs_new RENAME TO health_gate_runs;
                CREATE INDEX IF NOT EXISTS idx_health_gate_runs_node_stack
                  ON health_gate_runs(node_id, stack_name, started_at);
                CREATE INDEX IF NOT EXISTS idx_health_gate_runs_deployed_gen
                  ON health_gate_runs(node_id, stack_name, deployed_generation_id);
            `);
        })();
        this.ensureHealthGateDeployedGenerationColumn();
    }

    private ensureHealthGateDeployedGenerationColumn(): void {
        this.tryAddColumn('health_gate_runs', 'deployed_generation_id', 'TEXT');
        this.db.exec(`
            CREATE INDEX IF NOT EXISTS idx_health_gate_runs_deployed_gen
              ON health_gate_runs(node_id, stack_name, deployed_generation_id);
        `);
    }

    private migrateEncryptNodeTokens(): void {
        const crypto = CryptoService.getInstance();
        const rows = this.db.prepare("SELECT id, api_token FROM nodes WHERE api_token != '' AND api_token IS NOT NULL").all() as Array<{ id: number; api_token: string }>;
        for (const row of rows) {
            if (!crypto.isEncrypted(row.api_token)) {
                const encrypted = crypto.encrypt(row.api_token);
                this.db.prepare('UPDATE nodes SET api_token = ? WHERE id = ?').run(encrypted, row.id);
            }
        }
    }

    private migrateSSOColumns(): void {
        const maybeAddCol = (table: string, col: string, def: string) => {
            try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e: unknown) {
                // Expected: column already exists
                if (!String((e as Error)?.message).includes('duplicate column')) {
                    console.warn(`[DatabaseService] Unexpected error adding column "${col}" to "${table}":`, (e as Error).message);
                }
            }
        };
        maybeAddCol('users', 'auth_provider', "TEXT NOT NULL DEFAULT 'local'");
        maybeAddCol('users', 'provider_id', 'TEXT DEFAULT NULL');
        maybeAddCol('users', 'email', 'TEXT DEFAULT NULL');
        maybeAddCol('users', 'token_version', 'INTEGER NOT NULL DEFAULT 1');

        this.db.exec(`
            CREATE TABLE IF NOT EXISTS sso_config (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                provider TEXT NOT NULL UNIQUE,
                enabled INTEGER DEFAULT 0,
                config_json TEXT NOT NULL DEFAULT '{}',
                created_at INTEGER NOT NULL,
                updated_at INTEGER NOT NULL
            );
            CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider ON users(auth_provider, provider_id) WHERE provider_id IS NOT NULL;
        `);
    }

    private migrateRegistries(): void {
        this.db.exec(`
            CREATE TABLE IF NOT EXISTS registries (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                url TEXT NOT NULL,
                type TEXT NOT NULL DEFAULT 'custom',
                username TEXT NOT NULL DEFAULT '',
                secret TEXT NOT NULL DEFAULT '',
                aws_region TEXT DEFAULT NULL,
                created_at INTEGER NOT NULL,
                updated_at INTEGER NOT NULL
            );
        `);
    }

    private migrateRegistryDelivery(): void {
        this.db.exec(`
            CREATE TABLE IF NOT EXISTS registry_delivery_events (
                seq INTEGER PRIMARY KEY AUTOINCREMENT,
                event_id TEXT UNIQUE NOT NULL,
                delivery_source_id TEXT NOT NULL,
                stack TEXT,
                op TEXT,
                attestation_jti TEXT,
                prep_id_sha256 TEXT,
                temp_dir_id TEXT,
                event_type TEXT NOT NULL,
                source_hash TEXT,
                pruned_through_seq INTEGER,
                created_at INTEGER NOT NULL
            );
            CREATE INDEX IF NOT EXISTS idx_registry_delivery_events_source_seq
                ON registry_delivery_events(delivery_source_id, seq);

            CREATE TABLE IF NOT EXISTS registry_delivery_imported_events (
                event_id TEXT NOT NULL,
                delivery_source_id TEXT NOT NULL,
                seq INTEGER NOT NULL,
                hub_node_id_snapshot INTEGER NOT NULL,
                stack TEXT,
                op TEXT,
                attestation_jti TEXT,
                prep_id_sha256 TEXT,
                temp_dir_id TEXT,
                event_type TEXT NOT NULL,
                source_hash TEXT,
                pruned_through_seq INTEGER,
                created_at INTEGER NOT NULL,
                imported_at INTEGER NOT NULL,
                PRIMARY KEY (delivery_source_id, event_id)
            );

            CREATE TABLE IF NOT EXISTS registry_delivery_import_cursor (
                delivery_source_id TEXT PRIMARY KEY,
                last_seq INTEGER NOT NULL,
                updated_at INTEGER NOT NULL
            );
        `);

        const existing = this.db.prepare(
            'SELECT value FROM global_settings WHERE key = ?',
        ).get('delivery_source_id') as { value: string } | undefined;
        if (!existing) {
            const id = crypto.randomUUID();
            this.db.prepare(
                'INSERT INTO global_settings (key, value) VALUES (?, ?)',
            ).run('delivery_source_id', id);
            this.cachedGlobalSettings = null;
        }
    }

    private migrateRoleAssignments(): void {
        this.db.exec(`
            CREATE TABLE IF NOT EXISTS role_assignments (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                role TEXT NOT NULL,
                resource_type TEXT NOT NULL,
                resource_id TEXT NOT NULL,
                created_at INTEGER NOT NULL,
                FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
            );
            CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
            CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
        `);
        this.migrateRoleAssignmentsNodeQualified();
    }

    /**
     * Rebuild role_assignments with Mesh-style stack identity (node_id, resource_id).
     * Idempotent: probes sqlite_master for the final CHECK and both partial unique indexes.
     */
    private migrateRoleAssignmentsNodeQualified(): void {
        const tableSql = (this.db.prepare(
            "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'"
        ).get() as { sql: string } | undefined)?.sql ?? '';
        const indexRows = this.db.prepare(
            "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'"
        ).all() as Array<{ name: string; sql: string | null }>;
        const hasNodeIdColumn = (this.db.prepare(
            'PRAGMA table_info(role_assignments)'
        ).all() as Array<{ name: string }>).some((column) => column.name === 'node_id');
        const indexSqlByName = new Map(indexRows.map((r) => [r.name, r.sql ?? '']));
        const checkOk =
            tableSql.includes("resource_type = 'stack' AND node_id IS NOT NULL") &&
            tableSql.includes("resource_type = 'node' AND node_id IS NULL");
        const stackUniqueSql = indexSqlByName.get('idx_role_assignments_stack_unique') ?? '';
        const nodeUniqueSql = indexSqlByName.get('idx_role_assignments_node_unique') ?? '';
        const stackUniqueOk =
            stackUniqueSql.includes('user_id') &&
            stackUniqueSql.includes('role') &&
            stackUniqueSql.includes('resource_type') &&
            stackUniqueSql.includes('resource_id') &&
            stackUniqueSql.includes('node_id') &&
            /WHERE\s+resource_type\s*=\s*'stack'/i.test(stackUniqueSql);
        const nodeUniqueOk =
            nodeUniqueSql.includes('user_id') &&
            nodeUniqueSql.includes('role') &&
            nodeUniqueSql.includes('resource_type') &&
            nodeUniqueSql.includes('resource_id') &&
            /WHERE\s+resource_type\s*=\s*'node'/i.test(nodeUniqueSql) &&
            !/node_id/.test(nodeUniqueSql.replace(/WHERE[\s\S]*/i, ''));
        if (checkOk && stackUniqueOk && nodeUniqueOk) return;

        this.db.exec('DROP TABLE IF EXISTS role_assignments_new');

        // Do not call getDefaultNode(): NODE_COLUMNS may include columns not
        // yet added when this migration runs early in the constructor chain.
        const defaultNodeId = (
            this.db.prepare('SELECT id FROM nodes WHERE is_default = 1 LIMIT 1').get() as { id: number } | undefined
        )?.id ?? null;

        this.db.transaction(() => {
            this.db.exec(`
                CREATE TABLE role_assignments_new (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    user_id INTEGER NOT NULL,
                    role TEXT NOT NULL,
                    resource_type TEXT NOT NULL,
                    resource_id TEXT NOT NULL,
                    node_id INTEGER,
                    created_at INTEGER NOT NULL,
                    CHECK (
                        (resource_type = 'stack' AND node_id IS NOT NULL)
                        OR (resource_type = 'node' AND node_id IS NULL)
                    ),
                    FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
                    FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
                );
            `);

            this.db.exec(`
                INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at)
                SELECT id, user_id, role, resource_type, resource_id, NULL, created_at
                FROM role_assignments
                WHERE resource_type = 'node';
            `);

            if (hasNodeIdColumn) {
                this.db.exec(`
                    INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at)
                    SELECT id, user_id, role, resource_type, resource_id, node_id, created_at
                    FROM role_assignments
                    WHERE resource_type = 'stack' AND node_id IS NOT NULL
                `);
            } else if (defaultNodeId !== null) {
                this.db.prepare(`
                    INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at)
                    SELECT id, user_id, role, resource_type, resource_id, ?, created_at
                    FROM role_assignments
                    WHERE resource_type = 'stack'
                `).run(defaultNodeId);
            }
            // No default node: legacy stack rows are intentionally omitted (fail closed).

            this.db.exec(`
                DROP TABLE role_assignments;
                ALTER TABLE role_assignments_new RENAME TO role_assignments;
                CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
                CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
                CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_stack_unique
                    ON role_assignments(user_id, role, resource_type, resource_id, node_id)
                    WHERE resource_type = 'stack';
                CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_node_unique
                    ON role_assignments(user_id, role, resource_type, resource_id)
                    WHERE resource_type = 'node';
            `);
        })();
    }

    private migrateNotificationRoutes(): void {
        this.db.exec(`
            CREATE TABLE IF NOT EXISTS notification_routes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                stack_patterns TEXT NOT NULL,
                channel_type TEXT NOT NULL,
                channel_url TEXT NOT NULL,
                priority INTEGER NOT NULL DEFAULT 0,
                enabled INTEGER DEFAULT 1,
                created_at INTEGER NOT NULL,
                updated_at INTEGER NOT NULL
            );
            CREATE INDEX IF NOT EXISTS idx_notification_routes_priority ON notification_routes(priority);
        `);
        // Track external dispatch errors on notification records
        try { this.db.prepare('ALTER TABLE notification_history ADD COLUMN dispatch_error TEXT').run(); } catch { /* already exists */ }
    }

    private migrateNotificationRoutesNodeId(): void {
        try {
            this.db.prepare('ALTER TABLE notification_routes ADD COLUMN node_id INTEGER NULL').run();
        } catch {
            // column already present
        }
        this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notification_routes_node_priority ON notification_routes(node_id, enabled, priority)').run();
    }

    private tryAddColumn(table: string, col: string, def: string): boolean {
        try {
            this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run();
            return true;
        } catch (err) {
            const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
            if (!message.includes('duplicate column name')) throw err;
            return false;
        }
    }

    private migrateNotificationRoutesMatchers(): void {
        this.tryAddColumn('notification_routes', 'label_ids', 'TEXT NULL');
        this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
    }

    private migrateNotificationChannelConfig(): void {
        this.tryAddColumn('agents', 'config', 'TEXT NULL');
        this.tryAddColumn('agents', 'payload_template', 'TEXT NULL');
        this.tryAddColumn('notification_routes', 'config', 'TEXT NULL');
    }

    private migrateNotificationRouteLevels(): void {
        this.tryAddColumn('notification_routes', 'levels', 'TEXT NULL');
    }

    private migrateNotificationSuppressionRules(): void {
        this.db.exec(`
            CREATE TABLE IF NOT EXISTS notification_suppression_rules (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                node_id INTEGER NULL,
                stack_patterns TEXT NOT NULL,
                label_ids TEXT NULL,
                categories TEXT NULL,
                levels TEXT NULL,
                applies_to TEXT NOT NULL,
                enabled INTEGER DEFAULT 1,
                expires_at INTEGER NULL,
                created_at INTEGER NOT NULL,
                updated_at INTEGER NOT NULL
            );
            CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled
                ON notification_suppression_rules(enabled, expires_at);
            CREATE TABLE IF NOT EXISTS notification_suppression_rule_tombstones (
                id INTEGER PRIMARY KEY,
                deleted_at INTEGER NOT NULL
            );
        `);
        this.tryAddColumn('notification_suppression_rules', 'schedule', 'TEXT NULL');
        // kind + source_updated_at: hub-authored retract ordering. Legacy rows stay
        // permanent (fail closed); source_updated_at backfills from deleted_at for
        // audit continuity but is never compared to receiver wall clocks on write paths.
        this.tryAddColumn(
            'notification_suppression_rule_tombstones',
            'kind',
            "TEXT NOT NULL DEFAULT 'permanent'",
        );
        this.tryAddColumn(
            'notification_suppression_rule_tombstones',
            'source_updated_at',
            'INTEGER',
        );
        this.db.prepare(
            `UPDATE notification_suppression_rule_tombstones
             SET source_updated_at = deleted_at
             WHERE source_updated_at IS NULL`,
        ).run();
        this.db.exec(`
            CREATE TABLE IF NOT EXISTS notification_suppression_pending_retractions (
                rule_id INTEGER NOT NULL,
                node_id INTEGER NOT NULL,
                kind TEXT NOT NULL,
                source_updated_at INTEGER NOT NULL,
                created_at INTEGER NOT NULL,
                updated_at INTEGER NOT NULL,
                attempts INTEGER NOT NULL DEFAULT 0,
                last_error TEXT,
                PRIMARY KEY (rule_id, node_id)
            );
            CREATE INDEX IF NOT EXISTS idx_supp_pending_retract_node
                ON notification_suppression_pending_retractions(node_id);
        `);
    }

    private migrateNotificationHistoryContext(): void {
        this.tryAddColumn('notification_history', 'stack_name', 'TEXT');
        this.tryAddColumn('notification_history', 'container_name', 'TEXT');
        this.tryAddColumn('notification_history', 'suppression_match', 'TEXT');
    }

    private migrateStackDossierHashes(): void {
        this.tryAddColumn('stack_dossiers', 'source_hash', 'TEXT');
        this.tryAddColumn('stack_dossiers', 'rendered_hash', 'TEXT');
        this.tryAddColumn('stack_dossiers', 'last_drift_check_at', 'INTEGER');
    }

    private migrateGitSourceSshDeployKey(): void {
        this.tryAddColumn('stack_git_sources', 'encrypted_deploy_key', 'TEXT');
        this.tryAddColumn('stack_git_sources', 'ssh_known_hosts_entry', 'TEXT');
        this.tryAddColumn('stack_git_sources', 'ssh_host_key_fingerprint', 'TEXT');
    }

    private migrateGitSourcePrivateCa(): void {
        this.tryAddColumn('stack_git_sources', 'encrypted_ca_bundle', 'TEXT');
        this.tryAddColumn('gitops_create_checkpoints', 'encrypted_ca_bundle', 'TEXT');
    }

    private migrateGitSourceManifest(): void {
        // Cache columns for the managed-project manifest (the manifest FILE in
        // <DATA_DIR>/git-managed/<nodeId>/<stackName>/ is the source of truth).
        this.tryAddColumn('stack_git_sources', 'manifest_version', 'INTEGER');
        this.tryAddColumn('stack_git_sources', 'manifest_state', 'TEXT');
        this.tryAddColumn('stack_git_sources', 'manifest_generation', 'TEXT');
    }

    private migrateGitSourceChangePlan(): void {
        this.tryAddColumn('stack_git_sources', 'pending_plan_fingerprint', 'TEXT');
        this.tryAddColumn('stack_git_sources', 'pending_plan_blocked', 'INTEGER');
        this.tryAddColumn('stack_git_sources', 'pending_plan_summary', 'TEXT');
        this.tryAddColumn('stack_git_sources', 'last_plan_fingerprint', 'TEXT');
        this.tryAddColumn('stack_git_sources', 'last_plan_outcome', 'TEXT');
    }

    private migrateGitOpsRecoveryColumns(): void {
        this.tryAddColumn('stack_update_recovery_generations', 'gitops_generation_id', 'TEXT');
        this.tryAddColumn('stack_update_recovery_generations', 'gitops_artifact_set_id', 'TEXT');
        this.tryAddColumn('stack_update_recovery_generations', 'gitops_source_acceptance_ref', 'TEXT');
    }

    private migrateGitOpsCreateCheckpointSshDeployKey(): void {
        this.tryAddColumn('gitops_create_checkpoints', 'encrypted_deploy_key', 'TEXT');
        this.tryAddColumn('gitops_create_checkpoints', 'ssh_known_hosts_entry', 'TEXT');
        this.tryAddColumn('gitops_create_checkpoints', 'ssh_host_key_fingerprint', 'TEXT');
    }

    private migrateGitSourceMultiFile(): void {
        this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT');
        this.tryAddColumn('stack_git_sources', 'context_dir', 'TEXT');
        this.tryAddColumn('stack_git_sources', 'applied_deploy_spec', 'TEXT');
        // Backfill legacy rows so compose_paths is always a JSON array. parseGitSource
        // also normalizes on read, so this is belt-and-suspenders for any direct readers.
        try {
            this.db.prepare(
                `UPDATE stack_git_sources SET compose_paths = json_array(compose_path) WHERE compose_paths IS NULL`
            ).run();
        } catch (e) {
            console.warn('[DatabaseService] git-source compose_paths backfill skipped:', (e as Error).message);
        }
    }

    private migrateNodeUpdateSkips(): void {
        try {
            this.db.prepare(`
                CREATE TABLE IF NOT EXISTS node_update_skips (
                    node_id INTEGER PRIMARY KEY,
                    skipped_version TEXT NOT NULL,
                    skipped_at INTEGER NOT NULL,
                    skipped_by TEXT NOT NULL,
                    FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
                )
            `).run();
        } catch (e) {
            console.warn('[DatabaseService] node_update_skips migration:', (e as Error).message);
        }
    }

    private migrateStackAlertServiceScope(): void {
        this.tryAddColumn('stack_alerts', 'service_name', 'TEXT');
        try {
            // FK is not enforced (foreign_keys pragma off); deleteStackAlert removes children.
            this.db.prepare(`
                CREATE TABLE IF NOT EXISTS stack_alert_service_cooldowns (
                    alert_id INTEGER NOT NULL,
                    service_name TEXT NOT NULL,
                    last_fired_at INTEGER NOT NULL,
                    PRIMARY KEY (alert_id, service_name),
                    FOREIGN KEY (alert_id) REFERENCES stack_alerts(id) ON DELETE CASCADE
                )
            `).run();
        } catch (e) {
            console.error('[DatabaseService] stack_alert_service_cooldowns migration failed:', (e as Error).message);
            throw e;
        }
    }

    private migrateScanPolicyFleetColumns(): void {
        this.tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''");
        this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
    }

    /**
     * Risk-based deploy-gate inputs. The defaults preserve existing rows as
     * severity-only (block_on_severity=1, KEV/fixable off); new policies set
     * these explicitly to the risk-first posture at their create path.
     */
    private migrateScanPolicyRiskColumns(): void {
        this.tryAddColumn('scan_policies', 'block_on_severity', 'INTEGER NOT NULL DEFAULT 1');
        this.tryAddColumn('scan_policies', 'block_on_kev', 'INTEGER NOT NULL DEFAULT 0');
        this.tryAddColumn('scan_policies', 'block_on_fixable', 'INTEGER NOT NULL DEFAULT 0');
    }

    private migrateSecretMisconfigColumns(): void {
        this.tryAddColumn('vulnerability_scans', 'secret_count', 'INTEGER NOT NULL DEFAULT 0');
        this.tryAddColumn('vulnerability_scans', 'misconfig_count', 'INTEGER NOT NULL DEFAULT 0');
        this.tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'");
    }

    private migrateAgentsAndNotificationsNodeId(): void {
        this.tryAddColumn('agents', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
        this.tryAddColumn('notification_history', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
        const tryIndex = (sql: string, label: string) => {
            try {
                this.db.prepare(sql).run();
            } catch (e) {
                console.warn(`[DatabaseService] Could not create ${label}:`, (e as Error).message);
            }
        };
        tryIndex(
            'CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_node_type ON agents(node_id, type)',
            'agents(node_id, type) unique index',
        );
        tryIndex(
            'CREATE INDEX IF NOT EXISTS idx_notif_history_node_timestamp ON notification_history(node_id, timestamp DESC)',
            'notification_history(node_id, timestamp) index',
        );
    }

    private migratePolicyEvaluationColumn(): void {
        try {
            this.db
                .prepare('ALTER TABLE vulnerability_scans ADD COLUMN policy_evaluation TEXT')
                .run();
        } catch {
            /* column already present */
        }
    }

    private migrateNotificationCategory(): void {
        try {
            this.db.prepare('ALTER TABLE notification_history ADD COLUMN category TEXT').run();
        } catch {
            // column already present
        }
    }

    private migrateNotificationActor(): void {
        this.tryAddColumn('notification_history', 'actor_username', 'TEXT');
        try {
            this.db.prepare(
                'CREATE INDEX IF NOT EXISTS idx_notif_history_node_stack_ts ON notification_history(node_id, stack_name, timestamp DESC) WHERE stack_name IS NOT NULL'
            ).run();
        } catch {
            // index already present or partial-index syntax unsupported
        }
    }

    /**
     * A GitOps history/operation reference and a dedupe key, so notification
     * fanout for a settled GitOps attempt can be repaired from durable state
     * (retried at startup after a crash between commit and fanout) without
     * ever inserting a duplicate notification for the same attempt.
     */
    private migrateNotificationGitOpsDedupe(): void {
        this.tryAddColumn('notification_history', 'gitops_operation_id', 'TEXT');
        this.tryAddColumn('notification_history', 'dedupe_key', 'TEXT');
        try {
            this.db.prepare(
                'CREATE UNIQUE INDEX IF NOT EXISTS idx_notif_history_dedupe_key ON notification_history(dedupe_key) WHERE dedupe_key IS NOT NULL'
            ).run();
        } catch (err) {
            // Unlike a pure performance index, this one is the ON CONFLICT
            // target every addNotificationHistory() insert names. If it is
            // missing, every notification write in the product fails, not
            // just GitOps ones, so a silent catch here would turn into an
            // unexplained total outage instead of a diagnosable startup log.
            console.error('[DatabaseService] Failed to create notification dedupe index:', err);
        }
    }

    private migrateMeshTables(): void {
        try {
            if (isPilotMode()) {
                // Per C-3 design, mesh state lives on central. Pilots never write
                // to mesh_stacks; alias data arrives via the D-1 override push
                // and lives in MeshService.pilotAliasOverlay. Drop any leftover
                // rows from a prior central-mode boot and skip the CREATE.
                this.db.prepare('DROP TABLE IF EXISTS mesh_stacks').run();
            } else {
                this.db.prepare(`
                    CREATE TABLE IF NOT EXISTS mesh_stacks (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        node_id INTEGER NOT NULL,
                        stack_name TEXT NOT NULL,
                        created_at INTEGER NOT NULL,
                        created_by TEXT,
                        UNIQUE(node_id, stack_name),
                        FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
                    )
                `).run();
                this.db.prepare('CREATE INDEX IF NOT EXISTS idx_mesh_stacks_node ON mesh_stacks(node_id)').run();
            }
        } catch (e) {
            console.warn('[DatabaseService] mesh_stacks migration:', (e as Error).message);
        }
        try {
            this.db.prepare(`
                CREATE TABLE IF NOT EXISTS stack_project_env_files (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    node_id INTEGER NOT NULL,
                    stack_name TEXT NOT NULL,
                    env_file TEXT NOT NULL,
                    position INTEGER NOT NULL,
                    UNIQUE(node_id, stack_name, env_file),
                    FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
                )
            `).run();
            this.db.prepare('CREATE INDEX IF NOT EXISTS idx_stack_project_env_files_lookup ON stack_project_env_files(node_id, stack_name)').run();
        } catch (e) {
            console.warn('[DatabaseService] stack_project_env_files migration:', (e as Error).message);
        }
        // mesh_centrals was the peer-side cache of the reverse-callback JWT
        // (central → peer bootstrap material). Peer→central traffic now
        // multiplexes over the existing forward WS via `tcp_open_reverse`,
        // so the table is no longer written or read. Drop it on every boot;
        // idempotent.
        try { this.db.prepare('DROP TABLE IF EXISTS mesh_centrals').run(); }
        catch (e) { console.warn('[DatabaseService] Could not drop mesh_centrals:', (e as Error).message); }
        this.tryAddColumn('nodes', 'mesh_enabled', 'INTEGER NOT NULL DEFAULT 0');
    }

    private migrateNodeLabels(): void {
        try {
            this.db.prepare(`
                CREATE TABLE IF NOT EXISTS node_labels (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    node_id INTEGER NOT NULL,
                    label TEXT NOT NULL,
                    created_at INTEGER NOT NULL,
                    UNIQUE(node_id, label),
                    FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
                )
            `).run();
            this.db.prepare('CREATE INDEX IF NOT EXISTS idx_node_labels_node ON node_labels(node_id)').run();
            this.db.prepare('CREATE INDEX IF NOT EXISTS idx_node_labels_label ON node_labels(label)').run();
        } catch (e) {
            console.warn('[DatabaseService] Could not create node_labels:', (e as Error).message);
        }
    }

    private migrateBlueprints(): void {
        try {
            this.db.prepare(`
                CREATE TABLE IF NOT EXISTS blueprints (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    name TEXT NOT NULL UNIQUE,
                    description TEXT,
                    compose_content TEXT NOT NULL,
                    selector_json TEXT NOT NULL,
                    drift_mode TEXT NOT NULL DEFAULT 'suggest',
                    classification TEXT NOT NULL DEFAULT 'unknown',
                    classification_reasons TEXT,
                    enabled INTEGER NOT NULL DEFAULT 1,
                    revision INTEGER NOT NULL DEFAULT 1,
                    created_at INTEGER NOT NULL,
                    updated_at INTEGER NOT NULL,
                    created_by TEXT
                )
            `).run();
            this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprints_enabled ON blueprints(enabled)').run();
        } catch (e) {
            console.warn('[DatabaseService] Could not create blueprints:', (e as Error).message);
        }
        try {
            this.db.prepare(`
                CREATE TABLE IF NOT EXISTS blueprint_deployments (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    blueprint_id INTEGER NOT NULL,
                    node_id INTEGER NOT NULL,
                    status TEXT NOT NULL,
                    applied_revision INTEGER,
                    last_deployed_at INTEGER,
                    last_checked_at INTEGER,
                    last_drift_at INTEGER,
                    drift_summary TEXT,
                    last_error TEXT,
                    UNIQUE(blueprint_id, node_id),
                    FOREIGN KEY (blueprint_id) REFERENCES blueprints(id) ON DELETE CASCADE,
                    FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
                )
            `).run();
            this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_blueprint ON blueprint_deployments(blueprint_id)').run();
            this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_node ON blueprint_deployments(node_id)').run();
            this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_status ON blueprint_deployments(status)').run();
        } catch (e) {
            console.warn('[DatabaseService] Could not create blueprint_deployments:', (e as Error).message);
        }
    }

    private migrateAddNodeLastContact(): void {
        this.tryAddColumn('nodes', 'last_successful_contact', 'INTEGER');
    }

    private migrateAddNodeCordonFields(): void {
        this.tryAddColumn('nodes', 'cordoned', 'INTEGER NOT NULL DEFAULT 0');
        this.tryAddColumn('nodes', 'cordoned_at', 'INTEGER');
        this.tryAddColumn('nodes', 'cordoned_reason', 'TEXT');
    }

    private migrateAddBlueprintPinnedNode(): void {
        this.tryAddColumn('blueprints', 'pinned_node_id', 'INTEGER');
    }

    private migrateAddBlueprintApproval(): void {
        this.tryAddColumn('blueprints', 'approval_status', "TEXT NOT NULL DEFAULT 'pending'");
        this.tryAddColumn('blueprints', 'approved_intent_fingerprint', 'TEXT');
        this.tryAddColumn('blueprints', 'approved_blast_json', 'TEXT');
        this.tryAddColumn('blueprints', 'approved_at', 'INTEGER');
        this.tryAddColumn('blueprints', 'approved_by', 'TEXT');
        // Fail-closed backfill: any pre-existing approved-looking default stays pending.
        try {
            this.db.prepare(
                `UPDATE blueprints SET approval_status = 'pending',
                    approved_intent_fingerprint = NULL,
                    approved_blast_json = NULL,
                    approved_at = NULL,
                    approved_by = NULL
                 WHERE approval_status IS NULL OR approval_status NOT IN ('pending', 'approved')`,
            ).run();
        } catch (e) {
            console.warn('[DatabaseService] blueprint approval backfill:', (e as Error).message);
        }
    }

    private migrateFleetSyncStickyError(): void {
        this.tryAddColumn('fleet_sync_status', 'sticky_error_code', 'TEXT');
        this.tryAddColumn('fleet_sync_status', 'sticky_error_expected', 'TEXT');
        this.tryAddColumn('fleet_sync_status', 'sticky_error_got', 'TEXT');
    }

    private migrateAutoHealNodeId(): void {
        const markerKey = 'migration_auto_heal_node_scope_v1';
        const markerDone = this.getGlobalSettings()[markerKey] === '1';
        this.tryAddColumn('auto_heal_policies', 'node_id', 'INTEGER NOT NULL DEFAULT 1');
        this.tryAddColumn('auto_heal_policies', 'proxy_entitled_until', 'INTEGER NOT NULL DEFAULT 0');
        if (!markerDone) {
            const defaultNode = this.getDefaultNode();
            if (defaultNode?.id) {
                this.db.transaction(() => {
                    this.db.prepare('UPDATE auto_heal_policies SET node_id = ? WHERE node_id IS NULL OR node_id = 1').run(defaultNode.id);
                    this.updateGlobalSetting(markerKey, '1');
                })();
            } else {
                this.updateGlobalSetting(markerKey, '1');
            }
        }
    }

    // --- Sencho Mesh ---

    public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> {
        if (isPilotMode()) return [];
        const sql = nodeId !== undefined
            ? 'SELECT id, node_id, stack_name, created_at, created_by FROM mesh_stacks WHERE node_id = ?'
            : 'SELECT id, node_id, stack_name, created_at, created_by FROM mesh_stacks';
        const rows = nodeId !== undefined
            ? this.db.prepare(sql).all(nodeId)
            : this.db.prepare(sql).all();
        return rows as Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }>;
    }

    public isMeshStackEnabled(nodeId: number, stackName: string): boolean {
        if (isPilotMode()) return false;
        const row = this.db.prepare('SELECT 1 FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').get(nodeId, stackName);
        return !!row;
    }

    public insertMeshStack(nodeId: number, stackName: string, createdBy: string | null): void {
        if (isPilotMode()) {
            console.warn(`[DatabaseService] insertMeshStack ignored on pilot (node=${nodeId}, stack=${stackName})`);
            return;
        }
        this.db.prepare(
            'INSERT INTO mesh_stacks (node_id, stack_name, created_at, created_by) VALUES (?, ?, ?, ?)'
        ).run(nodeId, stackName, Date.now(), createdBy);
    }

    public deleteMeshStack(nodeId: number, stackName: string): void {
        if (isPilotMode()) return;
        this.db.prepare('DELETE FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
    }

    // --- Project env files ---

    public getStackProjectEnvFiles(nodeId: number, stackName: string): string[] {
        const rows = this.db.prepare(
            'SELECT env_file FROM stack_project_env_files WHERE node_id = ? AND stack_name = ? ORDER BY position ASC'
        ).all(nodeId, stackName) as Array<{ env_file: string }>;
        return rows.map(r => r.env_file);
    }

    public setStackProjectEnvFiles(nodeId: number, stackName: string, files: string[]): void {
        const deleteStmt = this.db.prepare('DELETE FROM stack_project_env_files WHERE node_id = ? AND stack_name = ?');
        const insertStmt = this.db.prepare(
            'INSERT INTO stack_project_env_files (node_id, stack_name, env_file, position) VALUES (?, ?, ?, ?)'
        );
        const tx = this.db.transaction((ordered: string[]) => {
            deleteStmt.run(nodeId, stackName);
            ordered.forEach((file, idx) => {
                insertStmt.run(nodeId, stackName, file, idx);
            });
        });
        tx(files);
    }

    public deleteStackProjectEnvFiles(nodeId: number, stackName: string): void {
        this.db.prepare('DELETE FROM stack_project_env_files WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
    }

    public setNodeMeshEnabled(nodeId: number, enabled: boolean): void {
        this.db.prepare('UPDATE nodes SET mesh_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, nodeId);
    }

    public getNodeMeshEnabled(nodeId: number): boolean {
        const row = this.db.prepare('SELECT mesh_enabled FROM nodes WHERE id = ?').get(nodeId) as { mesh_enabled?: number } | undefined;
        return !!row?.mesh_enabled;
    }

    // --- Node update skips ---

    public getNodeUpdateSkip(nodeId: number): { skippedVersion: string; skippedAt: number; skippedBy: string } | null {
        const row = this.db.prepare(
            'SELECT skipped_version, skipped_at, skipped_by FROM node_update_skips WHERE node_id = ?'
        ).get(nodeId) as { skipped_version: string; skipped_at: number; skipped_by: string } | undefined;
        if (!row) return null;
        return {
            skippedVersion: row.skipped_version,
            skippedAt: row.skipped_at,
            skippedBy: row.skipped_by,
        };
    }

    public setNodeUpdateSkip(nodeId: number, version: string, username: string): void {
        this.db.prepare(
            'INSERT OR REPLACE INTO node_update_skips (node_id, skipped_version, skipped_at, skipped_by) VALUES (?, ?, ?, ?)'
        ).run(nodeId, version, Date.now(), username);
    }

    public deleteNodeUpdateSkip(nodeId: number): void {
        this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(nodeId);
    }

    // --- Agents ---

    /** Encrypt Apprise secrets at rest so a downgraded binary's SELECT * cannot return raw keys/URLs. */
    private sealAppriseSecret(value: string | null | undefined): string | null {
        if (value == null) return null;
        if (value === '') return value;
        const crypto = CryptoService.getInstance();
        return crypto.isEncrypted(value) ? value : crypto.encrypt(value);
    }

    private openAppriseSecret(value: string | null | undefined): string | null {
        if (value == null) return null;
        return CryptoService.getInstance().decrypt(value);
    }

    /** Seal or pass through url/config depending on whether the channel is Apprise. */
    private storeAppriseFields(
        isApprise: boolean,
        url: string,
        config: string | null | undefined,
    ): { url: string; config: string | null } {
        if (!isApprise) return { url, config: config ?? null };
        return {
            url: this.sealAppriseSecret(url) ?? '',
            config: this.sealAppriseSecret(config ?? null),
        };
    }

    /**
     * Decrypt Apprise fields for one row. Corrupt ciphertext or a key mismatch
     * must not throw out of list/dispatch paths: one bad Apprise row would
     * otherwise 500 GET /agents and silently drop every notification channel.
     * Empty url/config forces the operator to re-enter credentials to repair.
     */
    private loadAppriseFields(
        isApprise: boolean,
        url: string,
        config: string | null | undefined,
    ): { url: string; config: string | null } {
        if (!isApprise) return { url, config: config ?? null };
        try {
            return {
                url: this.openAppriseSecret(url) ?? '',
                config: this.openAppriseSecret(config ?? null),
            };
        } catch (e) {
            console.error(
                '[DatabaseService] Failed to decrypt Apprise credentials; isolating row:',
                (e as Error).message,
            );
            return { url: '', config: null };
        }
    }

    private mapAgentRow(row: any): Agent {
        const type = row.type as Agent['type'];
        const fields = this.loadAppriseFields(type === 'apprise', row.url as string, row.config as string | null);
        return {
            ...row,
            type,
            enabled: row.enabled === 1,
            url: fields.url,
            config: fields.config,
        };
    }

    public getAgents(nodeId: number): Agent[] {
        const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ?');
        return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
    }

    public getEnabledAgents(nodeId: number): Agent[] {
        const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ? AND enabled = 1');
        return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
    }

    public upsertAgent(nodeId: number, agent: Agent): void {
        const stored = this.storeAppriseFields(agent.type === 'apprise', agent.url, agent.config);
        const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any;
        if (existing) {
            const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ?, config = ?, payload_template = ? WHERE node_id = ? AND type = ?');
            stmt.run(stored.url, agent.enabled ? 1 : 0, stored.config, agent.payload_template ?? null, nodeId, agent.type);
        } else {
            const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled, config, payload_template) VALUES (?, ?, ?, ?, ?, ?)');
            stmt.run(nodeId, agent.type, stored.url, agent.enabled ? 1 : 0, stored.config, agent.payload_template ?? null);
        }
    }

    // --- Notification Routes ---

    private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
        const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
        const fields = this.loadAppriseFields(
            channel_type === 'apprise',
            row.channel_url as string,
            row.config as string | null,
        );
        return {
            id: row.id as number,
            name: row.name as string,
            node_id: row.node_id != null ? (row.node_id as number) : null,
            stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
            label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
            categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
            levels: row.levels ? JSON.parse(row.levels as string) as ('info' | 'warning' | 'error')[] : null,
            channel_type,
            channel_url: fields.url,
            config: fields.config,
            priority: row.priority as number,
            enabled: row.enabled === 1,
            created_at: row.created_at as number,
            updated_at: row.updated_at as number,
        };
    }

    public getStackLabelIds(nodeId: number, stackName: string): number[] {
        const rows = this.db.prepare(
            'SELECT label_id FROM stack_label_assignments WHERE stack_name = ? AND node_id = ?'
        ).all(stackName, nodeId) as { label_id: number }[];
        return rows.map(r => r.label_id);
    }

    public getNotificationRoutes(): NotificationRoute[] {
        return this.db.prepare('SELECT * FROM notification_routes ORDER BY priority ASC')
            .all()
            .map((row) => this.parseNotificationRoute(row as Record<string, unknown>));
    }

    public getEnabledNotificationRoutes(): NotificationRoute[] {
        return this.db.prepare('SELECT * FROM notification_routes WHERE enabled = 1 ORDER BY priority ASC')
            .all()
            .map((row) => this.parseNotificationRoute(row as Record<string, unknown>));
    }

    public getNotificationRoute(id: number): NotificationRoute | undefined {
        const row = this.db.prepare('SELECT * FROM notification_routes WHERE id = ?').get(id) as Record<string, unknown> | undefined;
        return row ? this.parseNotificationRoute(row) : undefined;
    }

    public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
        const stored = this.storeAppriseFields(route.channel_type === 'apprise', route.channel_url, route.config);
        const result = this.db.prepare(
            'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, levels, channel_type, channel_url, config, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        ).run(
            route.name,
            route.node_id ?? null,
            JSON.stringify(route.stack_patterns),
            route.label_ids ? JSON.stringify(route.label_ids) : null,
            route.categories ? JSON.stringify(route.categories) : null,
            route.levels && route.levels.length > 0 ? JSON.stringify(route.levels) : null,
            route.channel_type,
            stored.url,
            stored.config,
            route.priority,
            route.enabled ? 1 : 0,
            route.created_at,
            route.updated_at
        );
        return this.getNotificationRoute(result.lastInsertRowid as number)!;
    }

    public updateNotificationRoute(id: number, updates: Partial<Omit<NotificationRoute, 'id' | 'created_at'>>): void {
        const fields: string[] = [];
        const values: unknown[] = [];
        const existing = this.getNotificationRoute(id);
        const effectiveType = updates.channel_type ?? existing?.channel_type;
        const sealApprise = effectiveType === 'apprise';

        if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
        if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
        if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
        if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
        if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
        if ('levels' in updates) {
            fields.push('levels = ?');
            values.push(updates.levels && updates.levels.length > 0 ? JSON.stringify(updates.levels) : null);
        }
        if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); }
        if (updates.channel_url !== undefined) {
            fields.push('channel_url = ?');
            values.push(sealApprise ? (this.sealAppriseSecret(updates.channel_url) ?? '') : updates.channel_url);
        }
        if ('config' in updates) {
            fields.push('config = ?');
            values.push(sealApprise ? this.sealAppriseSecret(updates.config ?? null) : (updates.config ?? null));
        }
        if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
        if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
        if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }

        if (fields.length === 0) return;
        values.push(id);
        this.db.prepare(`UPDATE notification_routes SET ${fields.join(', ')} WHERE id = ?`).run(...values);
    }

    public deleteNotificationRoute(id: number): number {
        return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes;
    }

    // --- Notification Suppression Rules ---

    private parseNotificationSuppressionRule(row: Record<string, unknown>): NotificationSuppressionRule {
        const stored = parseStoredNotificationSchedule(row.schedule);
        if (stored.kind === 'invalid') {
            console.warn(
                `[DatabaseService] Ignoring corrupt notification suppression schedule on rule id=${row.id as number}`,
            );
        }
        return {
            id: row.id as number,
            name: row.name as string,
            node_id: row.node_id != null ? (row.node_id as number) : null,
            stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
            label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
            categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
            levels: row.levels ? JSON.parse(row.levels as string) as ('info' | 'warning' | 'error')[] : null,
            applies_to: row.applies_to as NotificationSuppressionAppliesTo,
            enabled: row.enabled === 1,
            expires_at: row.expires_at != null ? (row.expires_at as number) : null,
            schedule: stored.kind === 'ok' ? stored.schedule : null,
            scheduleInvalid: stored.kind === 'invalid',
            created_at: row.created_at as number,
            updated_at: row.updated_at as number,
        };
    }

    public getNotificationSuppressionRules(): NotificationSuppressionRule[] {
        return this.db.prepare('SELECT * FROM notification_suppression_rules ORDER BY created_at ASC')
            .all()
            .map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
    }

    public getEnabledNotificationSuppressionRules(now = Date.now()): NotificationSuppressionRule[] {
        return this.db.prepare(
            'SELECT * FROM notification_suppression_rules WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC',
        )
            .all(now)
            .map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
    }

    public getNotificationSuppressionRule(id: number): NotificationSuppressionRule | undefined {
        const row = this.db.prepare('SELECT * FROM notification_suppression_rules WHERE id = ?').get(id) as Record<string, unknown> | undefined;
        return row ? this.parseNotificationSuppressionRule(row) : undefined;
    }

    public createNotificationSuppressionRule(
        rule: Omit<NotificationSuppressionRule, 'id' | 'scheduleInvalid'>,
    ): NotificationSuppressionRule {
        const result = this.db.prepare(
            'INSERT INTO notification_suppression_rules (name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
        ).run(
            rule.name,
            rule.node_id ?? null,
            JSON.stringify(rule.stack_patterns),
            rule.label_ids ? JSON.stringify(rule.label_ids) : null,
            rule.categories ? JSON.stringify(rule.categories) : null,
            rule.levels ? JSON.stringify(rule.levels) : null,
            rule.applies_to,
            rule.enabled ? 1 : 0,
            rule.expires_at ?? null,
            rule.schedule ? JSON.stringify(rule.schedule) : null,
            rule.created_at,
            rule.updated_at,
        );
        return this.getNotificationSuppressionRule(result.lastInsertRowid as number)!;
    }

    private insertNotificationSuppressionRuleReplicaRow(
        rule: NotificationSuppressionRule,
        scheduleJson: string | null,
    ): void {
        this.db.prepare(
            `INSERT INTO notification_suppression_rules
                (id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        ).run(
            rule.id,
            rule.name,
            rule.node_id ?? null,
            JSON.stringify(rule.stack_patterns),
            rule.label_ids ? JSON.stringify(rule.label_ids) : null,
            rule.categories ? JSON.stringify(rule.categories) : null,
            rule.levels ? JSON.stringify(rule.levels) : null,
            rule.applies_to,
            rule.enabled ? 1 : 0,
            rule.expires_at ?? null,
            scheduleJson,
            rule.created_at,
            rule.updated_at,
        );
    }

    public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): SuppressionReplicaWriteOutcome {
        const scheduleJson = rule.schedule ? JSON.stringify(rule.schedule) : null;
        const existing = this.getNotificationSuppressionRule(rule.id);
        if (existing) {
            // Fleet sync is fire-and-forget with no delivery ordering guarantee: a
            // push that isn't strictly newer than what's stored must never overwrite it.
            if (existing.updated_at >= rule.updated_at) {
                console.warn(
                    `[DatabaseService] Ignoring stale suppression replica write for rule id=${sanitizeForLog(rule.id)} ` +
                        `(incoming updated_at=${sanitizeForLog(rule.updated_at)} <= stored updated_at=${sanitizeForLog(existing.updated_at)})`,
                );
                return 'ignored_stale';
            }
            this.db.prepare(
                `UPDATE notification_suppression_rules SET
                    name = ?, node_id = ?, stack_patterns = ?, label_ids = ?, categories = ?, levels = ?,
                    applies_to = ?, enabled = ?, expires_at = ?, schedule = ?, updated_at = ?
                 WHERE id = ?`,
            ).run(
                rule.name,
                rule.node_id ?? null,
                JSON.stringify(rule.stack_patterns),
                rule.label_ids ? JSON.stringify(rule.label_ids) : null,
                rule.categories ? JSON.stringify(rule.categories) : null,
                rule.levels ? JSON.stringify(rule.levels) : null,
                rule.applies_to,
                rule.enabled ? 1 : 0,
                rule.expires_at ?? null,
                scheduleJson,
                rule.updated_at,
                rule.id,
            );
            return 'applied';
        }
        // Re-read tombstone inside the transaction so a concurrent permanent
        // retract cannot be cleared after an outer eligibility check.
        let outcome: SuppressionReplicaWriteOutcome = 'applied';
        this.transaction(() => {
            const tombstone = this.db.prepare(
                `SELECT id, deleted_at, kind, source_updated_at
                 FROM notification_suppression_rule_tombstones WHERE id = ?`,
            ).get(rule.id) as NotificationSuppressionRuleTombstone | undefined;
            if (tombstone) {
                const kind = normalizeSuppressionRetractionKind(tombstone.kind);
                // Keep raw Number here: invalid watermarks must fail closed (block recreate),
                // not coerce to 0 the way safeTombstoneSourceUpdatedAt does for merges/reads.
                const sourceUpdatedAt = Number(tombstone.source_updated_at);
                if (kind === 'permanent') {
                    console.warn(
                        `[DatabaseService] Ignoring suppression replica write for rule id=${sanitizeForLog(rule.id)}: ` +
                            `permanent tombstone (source_updated_at=${sanitizeForLog(sourceUpdatedAt)})`,
                    );
                    outcome = 'ignored_permanent_tombstone';
                    return;
                }
                if (!Number.isSafeInteger(sourceUpdatedAt) || rule.updated_at <= sourceUpdatedAt) {
                    console.warn(
                        `[DatabaseService] Ignoring suppression replica write for rule id=${sanitizeForLog(rule.id)}: ` +
                            `recoverable tombstone source_updated_at=${sanitizeForLog(sourceUpdatedAt)}; ` +
                            `incoming updated_at=${sanitizeForLog(rule.updated_at)} is not newer`,
                    );
                    outcome = 'ignored_recoverable_watermark';
                    return;
                }
                this.db.prepare(
                    'DELETE FROM notification_suppression_rule_tombstones WHERE id = ?',
                ).run(rule.id);
            }
            this.insertNotificationSuppressionRuleReplicaRow(rule, scheduleJson);
            outcome = 'applied';
        });
        return outcome;
    }

    public updateNotificationSuppressionRule(
        id: number,
        updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'scheduleInvalid'>>,
    ): void {
        const fields: string[] = [];
        const values: unknown[] = [];

        if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
        if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
        if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
        if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
        if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
        if ('levels' in updates) { fields.push('levels = ?'); values.push(updates.levels ? JSON.stringify(updates.levels) : null); }
        if (updates.applies_to !== undefined) { fields.push('applies_to = ?'); values.push(updates.applies_to); }
        if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
        if ('expires_at' in updates) { fields.push('expires_at = ?'); values.push(updates.expires_at ?? null); }
        if ('schedule' in updates) {
            fields.push('schedule = ?');
            values.push(updates.schedule ? JSON.stringify(updates.schedule) : null);
        }
        if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }

        if (fields.length === 0) return;
        values.push(id);
        this.db.prepare(`UPDATE notification_suppression_rules SET ${fields.join(', ')} WHERE id = ?`).run(...values);
    }

    /**
     * Delete a suppression rule and record a hub-authored retraction tombstone.
     * Optional retraction defaults to permanent/0 (fail closed) for one-arg callers.
     * Recoverable DELETEs that are strictly older than a stored row are ignored
     * (no durable mutation). Permanent always applies. Row delete + tombstone
     * upsert are one transaction.
     */
    public deleteNotificationSuppressionRule(
        id: number,
        retraction: NotificationSuppressionRetraction = { kind: 'permanent', source_updated_at: 0 },
    ): { changes: number; outcome: SuppressionReplicaDeleteOutcome } {
        const kind = normalizeSuppressionRetractionKind(retraction.kind);
        const sourceUpdatedAt = retraction.source_updated_at;

        return this.transaction(() => {
            const existing = this.getNotificationSuppressionRule(id);
            if (
                kind === 'recoverable' &&
                existing &&
                existing.updated_at > sourceUpdatedAt
            ) {
                console.warn(
                    `[DatabaseService] Ignoring stale recoverable suppression DELETE for rule id=${sanitizeForLog(id)}: ` +
                        `source_updated_at=${sanitizeForLog(sourceUpdatedAt)} < stored updated_at=${sanitizeForLog(existing.updated_at)}`,
                );
                return { changes: 0, outcome: 'ignored_stale' };
            }

            const changes = this.db.prepare(
                'DELETE FROM notification_suppression_rules WHERE id = ?',
            ).run(id).changes;

            const prior = this.db.prepare(
                `SELECT kind, source_updated_at FROM notification_suppression_rule_tombstones WHERE id = ?`,
            ).get(id) as { kind: string; source_updated_at: number } | undefined;

            let mergedKind = kind;
            let mergedSource = sourceUpdatedAt;
            if (prior) {
                const priorKind = normalizeSuppressionRetractionKind(prior.kind);
                // Permanent wins over recoverable; watermark always takes the max.
                mergedKind =
                    priorKind === 'permanent' || kind === 'permanent' ? 'permanent' : 'recoverable';
                mergedSource = Math.max(
                    safeTombstoneSourceUpdatedAt(prior.source_updated_at),
                    sourceUpdatedAt,
                );
            }

            this.db.prepare(
                `INSERT INTO notification_suppression_rule_tombstones (id, deleted_at, kind, source_updated_at)
                 VALUES (?, ?, ?, ?)
                 ON CONFLICT(id) DO UPDATE SET
                    deleted_at = excluded.deleted_at,
                    kind = excluded.kind,
                    source_updated_at = excluded.source_updated_at`,
            ).run(id, Date.now(), mergedKind, mergedSource);

            return { changes, outcome: 'applied' };
        });
    }

    public getNotificationSuppressionRuleTombstone(
        id: number,
    ): NotificationSuppressionRuleTombstone | undefined {
        const row = this.db.prepare(
            `SELECT id, deleted_at, kind, source_updated_at
             FROM notification_suppression_rule_tombstones WHERE id = ?`,
        ).get(id) as NotificationSuppressionRuleTombstone | undefined;
        if (!row) return undefined;
        return {
            id: row.id,
            deleted_at: row.deleted_at,
            kind: normalizeSuppressionRetractionKind(row.kind),
            // Keep || 0 (not safeTombstoneSourceUpdatedAt): public reads historically
            // surface any truthy Number() result; merge/write paths coerce separately.
            source_updated_at: Number(row.source_updated_at) || 0,
        };
    }


    public upsertNotificationSuppressionPendingRetraction(row: {
        rule_id: number;
        node_id: number;
        kind: NotificationSuppressionRetractionKind;
        source_updated_at: number;
        last_error?: string;
    }): void {
        const now = Date.now();
        const kind = normalizeSuppressionRetractionKind(row.kind);
        const prior = this.db.prepare(
            `SELECT kind, source_updated_at, attempts FROM notification_suppression_pending_retractions
             WHERE rule_id = ? AND node_id = ?`,
        ).get(row.rule_id, row.node_id) as { kind: string; source_updated_at: number; attempts: number } | undefined;
        let mergedKind = kind;
        let mergedSource = row.source_updated_at;
        let attempts = 1;
        if (prior) {
            const priorKind = normalizeSuppressionRetractionKind(prior.kind);
            mergedKind =
                priorKind === 'permanent' || kind === 'permanent' ? 'permanent' : 'recoverable';
            mergedSource = Math.max(
                safeTombstoneSourceUpdatedAt(prior.source_updated_at),
                row.source_updated_at,
            );
            attempts = (prior.attempts || 0) + 1;
        }
        this.db.prepare(
            `INSERT INTO notification_suppression_pending_retractions
                (rule_id, node_id, kind, source_updated_at, created_at, updated_at, attempts, last_error)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?)
             ON CONFLICT(rule_id, node_id) DO UPDATE SET
                kind = excluded.kind,
                source_updated_at = excluded.source_updated_at,
                updated_at = excluded.updated_at,
                attempts = excluded.attempts,
                last_error = excluded.last_error`,
        ).run(
            row.rule_id,
            row.node_id,
            mergedKind,
            mergedSource,
            now,
            now,
            attempts,
            row.last_error ?? null,
        );
    }

    public deleteNotificationSuppressionPendingRetraction(ruleId: number, nodeId: number): void {
        this.db.prepare(
            'DELETE FROM notification_suppression_pending_retractions WHERE rule_id = ? AND node_id = ?',
        ).run(ruleId, nodeId);
    }

    public listNotificationSuppressionPendingRetractions(
        nodeId?: number,
    ): NotificationSuppressionPendingRetraction[] {
        const rows = (
            nodeId == null
                ? this.db.prepare(
                    `SELECT * FROM notification_suppression_pending_retractions ORDER BY updated_at ASC`,
                ).all()
                : this.db.prepare(
                    `SELECT * FROM notification_suppression_pending_retractions
                     WHERE node_id = ? ORDER BY updated_at ASC`,
                ).all(nodeId)
        ) as Array<Record<string, unknown>>;
        return rows.map((r) => ({
            rule_id: r.rule_id as number,
            node_id: r.node_id as number,
            kind: normalizeSuppressionRetractionKind(r.kind),
            source_updated_at: Number(r.source_updated_at) || 0,
            created_at: r.created_at as number,
            updated_at: r.updated_at as number,
            attempts: (r.attempts as number) || 0,
            last_error: (r.last_error as string | null) ?? null,
        }));
    }

    // --- Global Settings ---

    public getGlobalSettings(): Readonly<Record<string, string>> {
        if (this.cachedGlobalSettings) return this.cachedGlobalSettings;
        const stmt = this.db.prepare('SELECT * FROM global_settings');
        const rows = stmt.all() as Array<{ key: string; value: string }>;
        const settings: Record<string, string> = {};
        for (const row of rows) settings[row.key] = row.value;
        this.cachedGlobalSettings = Object.freeze(settings);
        return this.cachedGlobalSettings;
    }

    public updateGlobalSetting(key: string, value: string): void {
        const stmt = this.db.prepare('INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)');
        stmt.run(key, value);
        this.cachedGlobalSettings = null;
    }

    /**
     * Read one global_settings value straight from SQLite, ignoring the
     * in-process cache. Emergency CLIs (enableLocalLogin, disableSso) write
     * from a sidecar Node process; the running server's getGlobalSettings()
     * cache cannot see those writes. Use this for settings that must honor
     * out-of-process recovery without a restart.
     */
    public getGlobalSettingFresh(key: string): string | undefined {
        const row = this.db.prepare('SELECT value FROM global_settings WHERE key = ?').get(key) as
            | { value: string }
            | undefined;
        return row?.value;
    }

    // --- System State (operational/runtime values - not user-defined config) ---

    public getSystemState(key: string): string | null {
        const row = this.db.prepare('SELECT value FROM system_state WHERE key = ?').get(key) as { value: string } | undefined;
        return row?.value ?? null;
    }

    public setSystemState(key: string, value: string): void {
        this.db.prepare('INSERT OR REPLACE INTO system_state (key, value) VALUES (?, ?)').run(key, value);
    }

    /**
     * Persisted PilotMetrics counters. Returns the parsed JSON object (a
     * numeric record keyed by counter name) or null when the row is missing
     * or unparseable. Callers (PilotMetrics.load) handle per-field defaulting
     * so a missing counter in the persisted blob does not break a new release
     * that added the counter.
     *
     * This is the first JSON blob stored in `system_state`; mirror this
     * parse/validate shape (object check + numeric filter) for any future
     * JSON-shaped system_state row so an operator-edited row cannot crash
     * boot.
     */
    public getPilotMetricsCounters(): Record<string, number> | null {
        const raw = this.getSystemState(PILOT_METRICS_COUNTERS_KEY);
        if (raw === null) return null;
        try {
            const parsed = JSON.parse(raw) as unknown;
            if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
            const out: Record<string, number> = {};
            for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
                if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
            }
            return out;
        } catch (err) {
            console.warn('[DatabaseService] pilot_metrics_counters JSON parse failed:', (err as Error).message);
            return null;
        }
    }

    public setPilotMetricsCounters(counters: Record<string, number>): void {
        this.setSystemState(PILOT_METRICS_COUNTERS_KEY, JSON.stringify(counters));
    }

    /**
     * Run `fn` inside a single SQLite transaction. better-sqlite3 promotes a
     * nested call to a SAVEPOINT, so callers can compose this with methods
     * that already wrap their own writes in `this.db.transaction(...)`.
     *
     * Used by FleetSync receive to keep the row replacement and the
     * received_pushed_at watermark write atomic. If either step fails, both
     * roll back.
     */
    public transaction<T>(fn: () => T): T {
        return this.db.transaction(fn)();
    }

    // --- Stack Alerts ---

    public getStackAlerts(stackName?: string): StackAlert[] {
        if (stackName) {
            return this.db.prepare('SELECT * FROM stack_alerts WHERE stack_name = ?').all(stackName) as StackAlert[];
        }
        return this.db.prepare('SELECT * FROM stack_alerts').all() as StackAlert[];
    }

    public getStackAlert(id: number): StackAlert | undefined {
        return this.db.prepare('SELECT * FROM stack_alerts WHERE id = ?').get(id) as StackAlert | undefined;
    }

    public addStackAlert(alert: StackAlert): StackAlert {
        const stmt = this.db.prepare(
            'INSERT INTO stack_alerts (stack_name, service_name, metric, operator, threshold, duration_mins, cooldown_mins, last_fired_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
        );
        const result = stmt.run(
            alert.stack_name,
            alert.service_name ?? null,
            alert.metric,
            alert.operator,
            alert.threshold,
            alert.duration_mins,
            alert.cooldown_mins,
            alert.last_fired_at || 0
        );
        return this.db.prepare('SELECT * FROM stack_alerts WHERE id = ?').get(result.lastInsertRowid) as StackAlert;
    }

    /**
     * Delete an alert and its per-service cooldown rows.
     * SQLite foreign_keys is not enabled here, so child rows are removed
     * explicitly rather than relying on ON DELETE CASCADE.
     */
    public deleteStackAlert(id: number): void {
        this.transaction(() => {
            this.deleteStackAlertServiceCooldowns(id);
            this.db.prepare('DELETE FROM stack_alerts WHERE id = ?').run(id);
        });
    }

    public updateStackAlertLastFired(id: number, timestamp: number): void {
        const stmt = this.db.prepare('UPDATE stack_alerts SET last_fired_at = ? WHERE id = ?');
        stmt.run(timestamp, id);
    }

    public getStackAlertServiceCooldown(alertId: number, serviceName: string): number | null {
        const row = this.db.prepare(
            'SELECT last_fired_at FROM stack_alert_service_cooldowns WHERE alert_id = ? AND service_name = ?'
        ).get(alertId, serviceName) as { last_fired_at: number } | undefined;
        return row?.last_fired_at ?? null;
    }

    public hasAnyStackAlertServiceCooldown(alertId: number): boolean {
        const row = this.db.prepare(
            'SELECT 1 AS present FROM stack_alert_service_cooldowns WHERE alert_id = ? LIMIT 1'
        ).get(alertId) as { present: number } | undefined;
        return !!row;
    }

    public upsertStackAlertServiceCooldown(alertId: number, serviceName: string, timestamp: number): void {
        this.db.prepare(`
            INSERT INTO stack_alert_service_cooldowns (alert_id, service_name, last_fired_at)
            VALUES (?, ?, ?)
            ON CONFLICT(alert_id, service_name) DO UPDATE SET last_fired_at = excluded.last_fired_at
        `).run(alertId, serviceName, timestamp);
    }

    public deleteStackAlertServiceCooldowns(alertId: number): void {
        this.db.prepare('DELETE FROM stack_alert_service_cooldowns WHERE alert_id = ?').run(alertId);
    }

    // --- Auto-Heal Policies ---

    public getAutoHealPolicies(stackName?: string, nodeId?: number): AutoHealPolicy[] {
        if (stackName && nodeId !== undefined) {
            return this.db.prepare('SELECT * FROM auto_heal_policies WHERE stack_name = ? AND node_id = ?').all(stackName, nodeId) as AutoHealPolicy[];
        }
        if (stackName) {
            return this.db.prepare('SELECT * FROM auto_heal_policies WHERE stack_name = ?').all(stackName) as AutoHealPolicy[];
        }
        if (nodeId !== undefined) {
            return this.db.prepare('SELECT * FROM auto_heal_policies WHERE node_id = ?').all(nodeId) as AutoHealPolicy[];
        }
        return this.db.prepare('SELECT * FROM auto_heal_policies').all() as AutoHealPolicy[];
    }

    public getAutoHealPolicy(id: number): AutoHealPolicy | undefined {
        return this.db.prepare('SELECT * FROM auto_heal_policies WHERE id = ?').get(id) as AutoHealPolicy | undefined;
    }

    public addAutoHealPolicy(policy: Omit<AutoHealPolicy, 'id'>): AutoHealPolicy {
        const stmt = this.db.prepare(
            'INSERT INTO auto_heal_policies (node_id, proxy_entitled_until, stack_name, service_name, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures, enabled, consecutive_failures, last_fired_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        );
        const result = stmt.run(
            policy.node_id,
            policy.proxy_entitled_until,
            policy.stack_name,
            policy.service_name ?? null,
            policy.unhealthy_duration_mins,
            policy.cooldown_mins,
            policy.max_restarts_per_hour,
            policy.auto_disable_after_failures,
            policy.enabled,
            policy.consecutive_failures,
            policy.last_fired_at,
            policy.created_at,
            policy.updated_at
        );
        return this.db.prepare('SELECT * FROM auto_heal_policies WHERE id = ?').get(result.lastInsertRowid) as AutoHealPolicy;
    }

    public updateAutoHealPolicy(id: number, patch: Partial<Omit<AutoHealPolicy, 'id' | 'stack_name' | 'created_at'>>): void {
        const ALLOWED_KEYS = new Set([
            'service_name', 'unhealthy_duration_mins', 'cooldown_mins',
            'max_restarts_per_hour', 'auto_disable_after_failures',
            'enabled', 'consecutive_failures', 'last_fired_at', 'proxy_entitled_until',
        ]);
        const entries = Object.entries(patch).filter(([k, v]) => ALLOWED_KEYS.has(k) && v !== undefined);
        if (entries.length === 0) return;
        const fields = entries.map(([k]) => `${k} = ?`).join(', ');
        const values = entries.map(([, v]) => v);
        this.db.prepare(`UPDATE auto_heal_policies SET ${fields}, updated_at = ? WHERE id = ?`).run(...values, Date.now(), id);
    }

    public deleteAutoHealPolicy(id: number): void {
        this.db.transaction(() => {
            this.db.prepare('DELETE FROM auto_heal_history WHERE policy_id = ?').run(id);
            this.db.prepare('DELETE FROM auto_heal_policies WHERE id = ?').run(id);
        })();
    }

    public recordAutoHealHistory(entry: Omit<AutoHealHistoryEntry, 'id'>): void {
        this.db.prepare(
            'INSERT INTO auto_heal_history (policy_id, stack_name, service_name, container_name, container_id, action, reason, success, error, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        ).run(
            entry.policy_id,
            entry.stack_name,
            entry.service_name ?? null,
            entry.container_name,
            entry.container_id,
            entry.action,
            entry.reason,
            entry.success,
            entry.error ?? null,
            entry.timestamp
        );
        this.pruneAutoHealHistory(entry.policy_id);
    }

    public getAutoHealHistory(policyId: number, limit = 50): AutoHealHistoryEntry[] {
        return this.db.prepare(
            'SELECT * FROM auto_heal_history WHERE policy_id = ? ORDER BY timestamp DESC LIMIT ?'
        ).all(policyId, limit) as AutoHealHistoryEntry[];
    }

    public pruneAutoHealHistory(policyId: number, maxRows = 500, maxAgeMs = 30 * 24 * 60 * 60_000): void {
        const cutoff = Date.now() - maxAgeMs;
        this.db.prepare('DELETE FROM auto_heal_history WHERE policy_id = ? AND timestamp < ?').run(policyId, cutoff);
        this.db.prepare(`
            DELETE FROM auto_heal_history
            WHERE policy_id = ?
              AND id NOT IN (
                SELECT id FROM auto_heal_history
                WHERE policy_id = ?
                ORDER BY timestamp DESC, id DESC
                LIMIT ?
              )
        `).run(policyId, policyId, maxRows);
    }

    public incrementConsecutiveFailures(policyId: number): void {
        this.db.prepare('UPDATE auto_heal_policies SET consecutive_failures = consecutive_failures + 1, updated_at = ? WHERE id = ?').run(Date.now(), policyId);
    }

    public resetConsecutiveFailures(policyId: number): void {
        this.db.prepare('UPDATE auto_heal_policies SET consecutive_failures = 0, updated_at = ? WHERE id = ?').run(Date.now(), policyId);
    }

    public setPolicyEnabled(policyId: number, enabled: boolean): void {
        this.db.prepare('UPDATE auto_heal_policies SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, Date.now(), policyId);
    }

    // --- Stack Dossiers ---

    public getStackDossier(nodeId: number, stackName: string): StackDossier | undefined {
        return this.db.prepare('SELECT * FROM stack_dossiers WHERE node_id = ? AND stack_name = ?').get(nodeId, stackName) as StackDossier | undefined;
    }

    public upsertStackDossier(nodeId: number, stackName: string, fields: StackDossierFields): StackDossier {
        const now = Date.now();
        this.db.prepare(
            `INSERT INTO stack_dossiers (
                node_id, stack_name, purpose, owner, access_urls, static_ip, vlan,
                firewall_notes, reverse_proxy_notes, backup_notes, upgrade_notes,
                recovery_notes, custom_notes, created_at, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(node_id, stack_name) DO UPDATE SET
                purpose = excluded.purpose,
                owner = excluded.owner,
                access_urls = excluded.access_urls,
                static_ip = excluded.static_ip,
                vlan = excluded.vlan,
                firewall_notes = excluded.firewall_notes,
                reverse_proxy_notes = excluded.reverse_proxy_notes,
                backup_notes = excluded.backup_notes,
                upgrade_notes = excluded.upgrade_notes,
                recovery_notes = excluded.recovery_notes,
                custom_notes = excluded.custom_notes,
                updated_at = excluded.updated_at`
        ).run(
            nodeId,
            stackName,
            fields.purpose,
            fields.owner,
            fields.access_urls,
            fields.static_ip,
            fields.vlan,
            fields.firewall_notes,
            fields.reverse_proxy_notes,
            fields.backup_notes,
            fields.upgrade_notes,
            fields.recovery_notes,
            fields.custom_notes,
            now,
            now
        );
        invalidateNodeNetworkingAggregate(nodeId);
        return this.getStackDossier(nodeId, stackName) as StackDossier;
    }

    public deleteStackDossier(nodeId: number, stackName: string): void {
        this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
        invalidateNodeNetworkingAggregate(nodeId);
    }

    /**
     * Record the deploy-time baseline hashes for a stack. Creates a dossier row
     * with empty operator notes if none exists; on conflict updates only the hash
     * columns so operator-authored notes and their updated_at are left untouched.
     */
    public setStackDossierHashes(nodeId: number, stackName: string, sourceHash: string, renderedHash: string | null): void {
        const now = Date.now();
        this.db.prepare(
            `INSERT INTO stack_dossiers (node_id, stack_name, source_hash, rendered_hash, created_at, updated_at)
             VALUES (?, ?, ?, ?, ?, ?)
             ON CONFLICT(node_id, stack_name) DO UPDATE SET
                source_hash = excluded.source_hash,
                rendered_hash = excluded.rendered_hash`
        ).run(nodeId, stackName, sourceHash, renderedHash, now, now);
    }

    /**
     * Stamp when the drift ledger was last reconciled for a stack (re-check, deploy,
     * or background scan). Mirrors setStackDossierHashes: creates a notes-empty row
     * if none exists, otherwise updates only this column so operator notes and their
     * updated_at are left untouched.
     */
    public setStackDossierDriftCheck(nodeId: number, stackName: string, checkedAt: number): void {
        const now = Date.now();
        this.db.prepare(
            `INSERT INTO stack_dossiers (node_id, stack_name, last_drift_check_at, created_at, updated_at)
             VALUES (?, ?, ?, ?, ?)
             ON CONFLICT(node_id, stack_name) DO UPDATE SET
                last_drift_check_at = excluded.last_drift_check_at`
        ).run(nodeId, stackName, checkedAt, now, now);
    }

    // --- Stack Drift Findings (the persisted drift ledger) ---

    public insertDriftFinding(f: Omit<StackDriftFindingRow, 'id' | 'resolved_at'>): number {
        const res = this.db.prepare(
            `INSERT INTO stack_drift_findings
                (node_id, stack_name, service, finding_type, severity, message, expected_json, actual_json, detected_at, resolved_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`
        ).run(f.node_id, f.stack_name, f.service, f.finding_type, f.severity, f.message, f.expected_json, f.actual_json, f.detected_at);
        return res.lastInsertRowid as number;
    }

    public resolveDriftFinding(id: number, resolvedAt: number): void {
        this.db.prepare('UPDATE stack_drift_findings SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL').run(resolvedAt, id);
    }

    public updateDriftFindingMessage(id: number, message: string): void {
        this.db.prepare('UPDATE stack_drift_findings SET message = ? WHERE id = ? AND resolved_at IS NULL').run(message, id);
    }

    /** Open (unresolved) findings for a stack, oldest first. */
    public getOpenDriftFindings(nodeId: number, stackName: string): StackDriftFindingRow[] {
        return this.db.prepare(
            'SELECT * FROM stack_drift_findings WHERE node_id = ? AND stack_name = ? AND resolved_at IS NULL ORDER BY detected_at ASC, id ASC'
        ).all(nodeId, stackName) as StackDriftFindingRow[];
    }

    /** Recent findings for a stack: open ones first, then resolved, each newest first. */
    public getRecentDriftFindings(nodeId: number, stackName: string, limit: number): StackDriftFindingRow[] {
        return this.db.prepare(
            'SELECT * FROM stack_drift_findings WHERE node_id = ? AND stack_name = ? ORDER BY (resolved_at IS NOT NULL) ASC, detected_at DESC, id DESC LIMIT ?'
        ).all(nodeId, stackName, limit) as StackDriftFindingRow[];
    }

    public deleteStackDriftFindings(nodeId: number, stackName: string): void {
        this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
    }

    // --- Stack Exposure Intent (per-stack and per-service exposure classification) ---

    /** All intent rows for a stack: the stack-level row (service '') and any per-service rows. */
    public getStackExposureIntents(nodeId: number, stackName: string): StackExposureIntentRow[] {
        return this.db.prepare(
            'SELECT * FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? ORDER BY service ASC'
        ).all(nodeId, stackName) as StackExposureIntentRow[];
    }

    /** Upsert one intent row. `service` is '' for the stack-level classification. */
    public setStackExposureIntent(nodeId: number, stackName: string, service: string, intent: ExposureIntent, updatedBy: string | null): void {
        this.db.prepare(
            `INSERT INTO stack_exposure_intent (node_id, stack_name, service, intent, updated_at, updated_by)
             VALUES (?, ?, ?, ?, ?, ?)
             ON CONFLICT(node_id, stack_name, service) DO UPDATE SET
                intent = excluded.intent,
                updated_at = excluded.updated_at,
                updated_by = excluded.updated_by`
        ).run(nodeId, stackName, service, intent, Date.now(), updatedBy);
        invalidateNodeNetworkingAggregate(nodeId);
    }

    /** Clear one intent row, leaving that scope unset; consumers treat a service with no row as inheriting the stack intent. */
    public deleteStackExposureIntent(nodeId: number, stackName: string, service: string): void {
        this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? AND service = ?').run(nodeId, stackName, service);
        invalidateNodeNetworkingAggregate(nodeId);
    }

    /** Clear every intent row for a stack (used when the stack is deleted). */
    public deleteStackExposureIntents(nodeId: number, stackName: string): void {
        this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
        invalidateNodeNetworkingAggregate(nodeId);
    }

    // --- Stack Exposure (Compose reachability descriptor) ---

    /** Store a per-stack exposure descriptor, replacing any prior row. */
    public upsertStackExposure(nodeId: number, stackName: string, descriptor: string, computedAt: number): void {
        this.db.prepare(
            `INSERT INTO stack_exposure (node_id, stack_name, descriptor, computed_at)
             VALUES (?, ?, ?, ?)
             ON CONFLICT (node_id, stack_name) DO UPDATE SET
               descriptor = excluded.descriptor,
               computed_at = excluded.computed_at`
        ).run(nodeId, stackName, descriptor, computedAt);
    }

    /** Return every cached exposure descriptor for a node. Malformed rows are
     *  skipped (logged) so a single corrupt row cannot fail the overview. */
    public getStackExposures(nodeId: number): StackExposureRow[] {
        const rows = this.db.prepare(
            'SELECT node_id, stack_name, descriptor, computed_at FROM stack_exposure WHERE node_id = ?'
        ).all(nodeId) as StackExposureRow[];
        return rows.filter((r) => {
            try {
                JSON.parse(r.descriptor);
                return true;
            } catch {
                console.warn('[DatabaseService] Dropping malformed stack_exposure row for node=%d stack=%s',
                    r.node_id, r.stack_name);
                return false;
            }
        });
    }

    /** Remove the exposure row for a single stack. */
    public deleteStackExposure(nodeId: number, stackName: string): void {
        this.db.prepare('DELETE FROM stack_exposure WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
    }

    // --- Compose Doctor / Preflight ---

    /** Store a run and its findings, replacing any prior run for this (node, stack). */
    public replacePreflightRun(run: PreflightRunRow, findings: PreflightFindingRow[]): void {
        this.transaction(() => {
            this.db.prepare(
                'DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ? AND stack_name = ?)'
            ).run(run.node_id, run.stack_name);
            this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ? AND stack_name = ?').run(run.node_id, run.stack_name);
            this.db.prepare(
                `INSERT INTO preflight_runs
                    (id, node_id, stack_name, source_hash, rendered_hash, service_images, status, highest_severity, created_at, created_by)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
            ).run(
                run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash,
                run.service_images ?? null, run.status, run.highest_severity, run.created_at, run.created_by,
            );
            const insert = this.db.prepare(
                `INSERT INTO preflight_findings
                    (id, run_id, rule_id, severity, title, message, source_path, remediation, service, created_at)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
            );
            for (const f of findings) {
                insert.run(f.id, f.run_id, f.rule_id, f.severity, f.title, f.message, f.source_path, f.remediation, f.service, f.created_at);
            }
        });
    }

    /** The most recent stored run for a stack, or undefined when none exists. */
    public getLatestPreflightRun(nodeId: number, stackName: string): PreflightRunRow | undefined {
        return this.db.prepare(
            'SELECT * FROM preflight_runs WHERE node_id = ? AND stack_name = ? ORDER BY created_at DESC, id DESC LIMIT 1'
        ).get(nodeId, stackName) as PreflightRunRow | undefined;
    }

    /** Findings for a run, in insertion order (the caller re-sorts by severity). */
    public getPreflightFindings(runId: string): PreflightFindingRow[] {
        return this.db.prepare(
            'SELECT * FROM preflight_findings WHERE run_id = ? ORDER BY rowid ASC'
        ).all(runId) as PreflightFindingRow[];
    }

    public getPreflightAcknowledgements(nodeId: number, stackName: string): PreflightAcknowledgement[] {
        return this.db.prepare(
            'SELECT * FROM preflight_acknowledgements WHERE node_id = ? AND stack_name = ? ORDER BY created_at DESC, id DESC',
        ).all(nodeId, stackName) as PreflightAcknowledgement[];
    }

    public getPreflightAcknowledgement(id: number): PreflightAcknowledgement | null {
        return (
            (this.db.prepare('SELECT * FROM preflight_acknowledgements WHERE id = ?')
                .get(id) as PreflightAcknowledgement | undefined) ?? null
        );
    }

    public upsertPreflightAcknowledgement(
        ack: Omit<PreflightAcknowledgement, 'id'>,
    ): PreflightAcknowledgement {
        const existing = this.db.prepare(
            `SELECT id FROM preflight_acknowledgements
             WHERE node_id = ? AND stack_name = ? AND rule_id = ? AND COALESCE(service, '') = COALESCE(?, '')`,
        ).get(ack.node_id, ack.stack_name, ack.rule_id, ack.service) as { id: number } | undefined;
        if (existing) {
            this.db.prepare(
                `UPDATE preflight_acknowledgements
                 SET reason = ?, expiry_mode = ?, expires_at = ?, anchor_rendered_hash = ?,
                     anchor_image_ref = ?, created_by = ?, created_at = ?
                 WHERE id = ?`,
            ).run(
                ack.reason, ack.expiry_mode, ack.expires_at, ack.anchor_rendered_hash,
                ack.anchor_image_ref, ack.created_by, ack.created_at, existing.id,
            );
            return this.getPreflightAcknowledgement(existing.id)!;
        }
        const result = this.db.prepare(
            `INSERT INTO preflight_acknowledgements
                (node_id, stack_name, rule_id, service, reason, expiry_mode, expires_at,
                 anchor_rendered_hash, anchor_image_ref, created_by, created_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        ).run(
            ack.node_id, ack.stack_name, ack.rule_id, ack.service, ack.reason, ack.expiry_mode,
            ack.expires_at, ack.anchor_rendered_hash, ack.anchor_image_ref, ack.created_by, ack.created_at,
        );
        return { ...ack, id: result.lastInsertRowid as number };
    }

    public deletePreflightAcknowledgement(id: number): boolean {
        const result = this.db.prepare('DELETE FROM preflight_acknowledgements WHERE id = ?').run(id);
        return result.changes > 0;
    }

    // --- Health Gate Runs ---

    public insertHealthGateRun(run: HealthGateRunRow): void {
        this.db.prepare(
            `INSERT INTO health_gate_runs
               (id, node_id, stack_name, trigger_action, status, reason, window_seconds, containers_json,
                started_at, ended_at, created_by, target_scope, service_name, failure_source,
                deployed_generation_id)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        ).run(
            run.id, run.node_id, run.stack_name, run.trigger_action, run.status, run.reason,
            run.window_seconds, run.containers_json, run.started_at, run.ended_at, run.created_by,
            run.target_scope, run.service_name, run.failure_source,
            run.deployed_generation_id ?? null,
        );
        // Bounded history: keep only the 10 most recent runs per stack.
        this.db.prepare(
            `DELETE FROM health_gate_runs
             WHERE node_id = ? AND stack_name = ?
               AND id NOT IN (
                 SELECT id FROM health_gate_runs
                 WHERE node_id = ? AND stack_name = ?
                 ORDER BY started_at DESC, id DESC LIMIT 10
               )`
        ).run(run.node_id, run.stack_name, run.node_id, run.stack_name);
    }

    public finalizeHealthGateRun(
        id: string,
        status: 'passed' | 'failed' | 'unknown',
        reason: string | null,
        endedAt: number,
        containersJson: string,
        failureSource: 'primary' | 'collateral' | null = null,
    ): void {
        this.db.prepare(
            'UPDATE health_gate_runs SET status = ?, reason = ?, ended_at = ?, containers_json = ?, failure_source = ? WHERE id = ?'
        ).run(status, reason, endedAt, containersJson, failureSource, id);
    }

    public getHealthGateRun(nodeId: number, stackName: string, id: string): HealthGateRunRow | undefined {
        return this.db.prepare(
            'SELECT * FROM health_gate_runs WHERE node_id = ? AND stack_name = ? AND id = ?'
        ).get(nodeId, stackName, id) as HealthGateRunRow | undefined;
    }

    public getLatestHealthGateRun(nodeId: number, stackName: string): HealthGateRunRow | undefined {
        return this.db.prepare(
            'SELECT * FROM health_gate_runs WHERE node_id = ? AND stack_name = ? ORDER BY started_at DESC, id DESC LIMIT 1'
        ).get(nodeId, stackName) as HealthGateRunRow | undefined;
    }

    /** Finalize runs left observing by a previous process (startup sweep). */
    /**
     * Runs a previous process left observing.
     *
     * Returned as rows rather than swept with one UPDATE because each has to be
     * finalized individually: the verdict is what the revision state listens
     * for, and a bulk update moves the rows while telling the model nothing.
     */
    public listObservingHealthGateRuns(): HealthGateRunRow[] {
        return this.db.prepare(
            "SELECT * FROM health_gate_runs WHERE status = 'observing'"
        ).all() as HealthGateRunRow[];
    }

    // --- Service Update Recovery ---

    public insertServiceUpdateRecovery(row: ServiceUpdateRecoveryRow): void {
        this.db.prepare(
            `INSERT INTO service_update_recovery
               (id, node_id, stack_name, service_name, replicas_json, majority_image_id,
                declared_image_ref, weak_floating_tag, health_gate_id, status,
                expires_at, claim_expires_at, created_at, created_by)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        ).run(
            row.id, row.node_id, row.stack_name, row.service_name, row.replicas_json, row.majority_image_id,
            row.declared_image_ref, row.weak_floating_tag, row.health_gate_id, row.status,
            row.expires_at, row.claim_expires_at, row.created_at, row.created_by,
        );
    }

    public getServiceUpdateRecovery(id: string): ServiceUpdateRecoveryRow | undefined {
        return this.db.prepare(
            'SELECT * FROM service_update_recovery WHERE id = ?'
        ).get(id) as ServiceUpdateRecoveryRow | undefined;
    }

    /** Active, unexpired rows for one service, most recent first. */
    public listActiveServiceUpdateRecoveries(nodeId: number, stackName: string, serviceName: string): ServiceUpdateRecoveryRow[] {
        return this.db.prepare(
            `SELECT * FROM service_update_recovery
             WHERE node_id = ? AND stack_name = ? AND service_name = ? AND status = 'active'
             ORDER BY created_at DESC`
        ).all(nodeId, stackName, serviceName) as ServiceUpdateRecoveryRow[];
    }

    /** Attach the update flow's own health gate run id while the row is still active. */
    public linkServiceUpdateRecoveryHealthGate(id: string, healthGateId: string): void {
        this.db.prepare(
            `UPDATE service_update_recovery SET health_gate_id = ? WHERE id = ? AND status = 'active'`
        ).run(healthGateId, id);
    }

    /**
     * Atomic claim CAS for restore: active -> restoring, only when the row has
     * not already expired. Returns the updated row, or undefined when another
     * claim, a sweep, or a terminal status won the race.
     */
    public claimServiceUpdateRecovery(id: string, claimExpiresAt: number, now: number): ServiceUpdateRecoveryRow | undefined {
        const result = this.db.prepare(
            `UPDATE service_update_recovery
             SET status = 'restoring', claim_expires_at = ?
             WHERE id = ? AND status = 'active' AND expires_at > ?`
        ).run(claimExpiresAt, id, now);
        if (result.changes === 0) return undefined;
        return this.getServiceUpdateRecovery(id);
    }

    /** Renewal CAS: only while still restoring. Cannot revive a consumed/expired/invalidated/active row. */
    public renewServiceUpdateRecoveryClaim(id: string, claimExpiresAt: number): boolean {
        const result = this.db.prepare(
            `UPDATE service_update_recovery SET claim_expires_at = ? WHERE id = ? AND status = 'restoring'`
        ).run(claimExpiresAt, id);
        return result.changes > 0;
    }

    /** Restore succeeded: restoring -> consumed, optionally linking the restore's own health gate run. */
    public markServiceUpdateRecoveryConsumed(id: string, healthGateId: string | null = null): boolean {
        const result = this.db.prepare(
            `UPDATE service_update_recovery
             SET status = 'consumed', claim_expires_at = NULL, health_gate_id = COALESCE(?, health_gate_id)
             WHERE id = ? AND status = 'restoring'`
        ).run(healthGateId, id);
        return result.changes > 0;
    }

    /** Mid-flight restore failure with the image still local: restoring -> active, claim cleared. */
    public reactivateServiceUpdateRecovery(id: string): boolean {
        const result = this.db.prepare(
            `UPDATE service_update_recovery SET status = 'active', claim_expires_at = NULL WHERE id = ? AND status = 'restoring'`
        ).run(id);
        return result.changes > 0;
    }

    /** Mid-flight restore failure with the image gone: restoring -> invalidated. */
    public invalidateServiceUpdateRecovery(id: string): boolean {
        const result = this.db.prepare(
            `UPDATE service_update_recovery SET status = 'invalidated', claim_expires_at = NULL WHERE id = ? AND status = 'restoring'`
        ).run(id);
        return result.changes > 0;
    }

    /** Startup/interval sweep: active rows whose TTL has lapsed. */
    public sweepExpiredActiveServiceUpdateRecoveries(now: number): number {
        const result = this.db.prepare(
            `UPDATE service_update_recovery SET status = 'expired' WHERE status = 'active' AND expires_at <= ?`
        ).run(now);
        return result.changes;
    }

    /**
     * Startup/interval sweep: restoring rows abandoned by a dead claim (process
     * died mid-restore). A restoring row with a live claim is never expired here,
     * even if its original expires_at has long passed.
     */
    public sweepAbandonedRestoringServiceUpdateRecoveries(now: number): number {
        const result = this.db.prepare(
            `UPDATE service_update_recovery
             SET status = 'expired'
             WHERE status = 'restoring' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?`
        ).run(now);
        return result.changes;
    }

    /** Image IDs currently protected from prune: active rows and restoring rows with a live claim. */
    public listHeldServiceUpdateRecoveryImageIds(nodeId: number, now: number): string[] {
        const rows = this.db.prepare(
            `SELECT DISTINCT majority_image_id FROM service_update_recovery
             WHERE node_id = ? AND (status = 'active' OR (status = 'restoring' AND claim_expires_at > ?))`
        ).all(nodeId, now) as Array<{ majority_image_id: string }>;
        return rows.map(r => r.majority_image_id);
    }

    public deleteServiceUpdateRecoveries(nodeId: number, stackName: string): void {
        this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
    }

    public insertStackUpdateRecoveryGeneration(row: StackUpdateRecoveryGenerationRow): void {
        this.db.prepare(
            `INSERT INTO stack_update_recovery_generations (
                id, node_id, stack_name, status, phase, is_current, backup_slot_id, content_path,
                operation_kind, override_path, services_json, health_gate_id, gate_retain_until,
                artifact_expires_at, operation_lease_expires_at, created_at, updated_at,
                created_by, artifacts_retired, gitops_generation_id, gitops_artifact_set_id,
                gitops_source_acceptance_ref
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        ).run(
            row.id, row.node_id, row.stack_name, row.status, row.phase, row.is_current,
            row.backup_slot_id, row.content_path ?? null, row.operation_kind ?? null,
            row.override_path, row.services_json, row.health_gate_id,
            row.gate_retain_until, row.artifact_expires_at, row.operation_lease_expires_at,
            row.created_at, row.updated_at, row.created_by, row.artifacts_retired ?? 0,
            row.gitops_generation_id ?? null, row.gitops_artifact_set_id ?? null,
            row.gitops_source_acceptance_ref ?? null,
        );
    }

    public getStackUpdateRecoveryGeneration(id: string): StackUpdateRecoveryGenerationRow | undefined {
        return this.db.prepare('SELECT * FROM stack_update_recovery_generations WHERE id = ?').get(id) as StackUpdateRecoveryGenerationRow | undefined;
    }

    public getCurrentStackUpdateRecovery(nodeId: number, stackName: string): StackUpdateRecoveryGenerationRow | undefined {
        return this.db.prepare(
            'SELECT * FROM stack_update_recovery_generations WHERE node_id = ? AND stack_name = ? AND is_current = 1 LIMIT 1'
        ).get(nodeId, stackName) as StackUpdateRecoveryGenerationRow | undefined;
    }

    public listStackUpdateRecoveryForStack(nodeId: number, stackName: string): StackUpdateRecoveryGenerationRow[] {
        return this.db.prepare(
            'SELECT * FROM stack_update_recovery_generations WHERE node_id = ? AND stack_name = ? ORDER BY created_at DESC'
        ).all(nodeId, stackName) as StackUpdateRecoveryGenerationRow[];
    }

    public listStackUpdateRecoveryGenerationsForNode(nodeId: number): StackUpdateRecoveryGenerationRow[] {
        return this.db.prepare(
            'SELECT * FROM stack_update_recovery_generations WHERE node_id = ? ORDER BY created_at DESC'
        ).all(nodeId) as StackUpdateRecoveryGenerationRow[];
    }

    public updateStackUpdateRecoveryGeneration(
        id: string,
        patch: Partial<Pick<StackUpdateRecoveryGenerationRow,
            'status' | 'phase' | 'is_current' | 'override_path' | 'health_gate_id' |
            'gate_retain_until' | 'artifact_expires_at' | 'operation_lease_expires_at' | 'services_json' |
            'content_path' | 'operation_kind'>>,
    ): void {
        const keys = Object.keys(patch) as Array<keyof typeof patch>;
        if (keys.length === 0) return;
        const sets = keys.map((k) => `${String(k)} = ?`).join(', ');
        const values = keys.map((k) => patch[k]);
        this.db.prepare(
            `UPDATE stack_update_recovery_generations SET ${sets}, updated_at = ? WHERE id = ?`
        ).run(...values, Date.now(), id);
    }

    public casStackUpdateRecoveryPhase(
        id: string,
        expectedPhase: StackUpdateRecoveryGenerationRow['phase'],
        nextPhase: StackUpdateRecoveryGenerationRow['phase'],
    ): boolean {
        const result = this.db.prepare(
            'UPDATE stack_update_recovery_generations SET phase = ?, updated_at = ? WHERE id = ? AND phase = ?'
        ).run(nextPhase, Date.now(), id, expectedPhase);
        return result.changes === 1;
    }

    /** Days a superseded generation's Docker/FS artifacts are retained before automatic cleanup. Never applies to the current generation. */
    public getRecoveryRetentionDays(): number {
        try {
            const raw = parseInt(this.getGlobalSettings()['recovery_retention_days'] ?? '', 10);
            return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 90) : DEFAULT_RECOVERY_RETENTION_DAYS;
        } catch (e) {
            console.warn('[DatabaseService] recovery_retention_days read failed; using default:', (e as Error).message);
            return DEFAULT_RECOVERY_RETENTION_DAYS;
        }
    }

    /** Total generations retained per stack, current included (0 = unlimited). */
    public getRecoveryMaxGenerations(): number {
        try {
            const raw = parseInt(this.getGlobalSettings()['recovery_max_generations'] ?? '', 10);
            return Number.isFinite(raw) && raw >= 0 ? Math.min(raw, 50) : DEFAULT_RECOVERY_MAX_GENERATIONS;
        } catch (e) {
            console.warn('[DatabaseService] recovery_max_generations read failed; using default:', (e as Error).message);
            return DEFAULT_RECOVERY_MAX_GENERATIONS;
        }
    }

    public casHandoffGeneration(candidateId: string, nodeId: number, stackName: string): boolean {
        const handoff = this.db.transaction(() => {
            const candidate = this.getStackUpdateRecoveryGeneration(candidateId);
            if (!candidate || candidate.node_id !== nodeId || candidate.stack_name !== stackName) return false;
            if (candidate.status !== 'candidate' || candidate.phase !== 'acquired') return false;
            const retentionMs = this.getRecoveryRetentionDays() * 24 * 60 * 60 * 1000;
            this.db.prepare(
                `UPDATE stack_update_recovery_generations
                 SET status = 'superseded', is_current = 0, artifact_expires_at = ?, updated_at = ?
                 WHERE node_id = ? AND stack_name = ? AND is_current = 1 AND id != ?`
            ).run(Date.now() + retentionMs, Date.now(), nodeId, stackName, candidateId);
            const result = this.db.prepare(
                `UPDATE stack_update_recovery_generations
                 SET status = 'active', is_current = 1, phase = 'handoff_committed', updated_at = ?
                 WHERE id = ? AND status = 'candidate' AND phase = 'acquired'`
            ).run(Date.now(), candidateId);
            return result.changes === 1;
        });
        return handoff();
    }


    /**
     * Generations whose Docker/FS artifacts can be retired (not actively held).
     * A manually released row (released_at set) is swept immediately regardless
     * of its expiry timers; a naturally abandoned/superseded row still waits out
     * artifact_expires_at / gate_retain_until.
     */
    public listStackUpdateRecoveryGenerationsForArtifactRetirement(now: number): StackUpdateRecoveryGenerationRow[] {
        return this.db.prepare(
            `SELECT * FROM stack_update_recovery_generations
             WHERE artifacts_retired = 0
               AND is_current = 0
               AND (
                 released_at IS NOT NULL
                 OR (
                   status IN ('abandoned', 'superseded')
                   AND (artifact_expires_at IS NULL OR artifact_expires_at <= ?)
                   AND (gate_retain_until IS NULL OR gate_retain_until <= ?)
                 )
               )`
        ).all(now, now) as StackUpdateRecoveryGenerationRow[];
    }

    /**
     * Superseded, not-yet-retired, not-released generations across every node
     * for cap enforcement (mirrors the other reconcile-sweep list methods,
     * which are also unscoped by node), newest first per (node_id, stack_name).
     */
    public listActiveSupersededGenerations(): StackUpdateRecoveryGenerationRow[] {
        return this.db.prepare(
            `SELECT * FROM stack_update_recovery_generations
             WHERE status = 'superseded' AND artifacts_retired = 0 AND released_at IS NULL
             ORDER BY node_id, stack_name, created_at DESC, id DESC`
        ).all() as StackUpdateRecoveryGenerationRow[];
    }

    public markStackUpdateRecoveryArtifactsRetired(id: string): boolean {
        const result = this.db.prepare(
            `UPDATE stack_update_recovery_generations
             SET artifacts_retired = 1, override_path = NULL, updated_at = ?
             WHERE id = ? AND artifacts_retired = 0`
        ).run(Date.now(), id);
        return result.changes === 1;
    }

    public abandonStackUpdateRecoveryGeneration(id: string): boolean {
        const now = Date.now();
        const result = this.db.prepare(
            `UPDATE stack_update_recovery_generations
             SET status = 'abandoned', is_current = 0, artifact_expires_at = ?,
                 operation_lease_expires_at = NULL, updated_at = ?
             WHERE id = ? AND status = 'candidate'`
        ).run(now, now, id);
        return result.changes === 1;
    }

    /**
     * Operator-initiated release of rollback protection. A single conditional
     * UPDATE both revalidates eligibility and performs the transition
     * atomically, so a stale caller can never release a row that has since
     * become ineligible (e.g. it started a health gate observation, or moved
     * to recovery_required). Only clears is_current/timestamps; Docker tag and
     * override-file cleanup is the caller's job via retireGenerationArtifacts,
     * matching how abandon() already separates the DB transition from cleanup.
     */
    public releaseStackUpdateRecoveryGeneration(id: string, releasedBy: string | null): boolean {
        const now = Date.now();
        const result = this.db.prepare(
            `UPDATE stack_update_recovery_generations
             SET released_at = ?, released_by = ?, is_current = 0, updated_at = ?
             WHERE id = ?
               AND released_at IS NULL
               AND artifacts_retired = 0
               AND phase = 'immediate_verified'
               AND status IN ('active', 'restored_current', 'superseded')
               AND (health_gate_id IS NULL OR NOT EXISTS (
                 SELECT 1 FROM health_gate_runs g
                 WHERE g.id = stack_update_recovery_generations.health_gate_id AND g.status = 'observing'
               ))`
        ).run(now, releasedBy, now, id);
        return result.changes === 1;
    }

    /** Pre-handoff candidates whose operation lease has expired. */
    public listStaleStackUpdateRecoveryCandidates(now: number): StackUpdateRecoveryGenerationRow[] {
        return this.db.prepare(
            `SELECT * FROM stack_update_recovery_generations
             WHERE status = 'candidate'
               AND operation_lease_expires_at IS NOT NULL
               AND operation_lease_expires_at <= ?`
        ).all(now) as StackUpdateRecoveryGenerationRow[];
    }

    /** Post-handoff generations stuck before immediate_verified with an expired lease. */
    public listStuckStackUpdateRecoveryGenerations(now: number): StackUpdateRecoveryGenerationRow[] {
        return this.db.prepare(
            `SELECT * FROM stack_update_recovery_generations
             WHERE status IN ('active', 'restored_current')
               AND phase IN ('handoff_committed', 'reconciling')
               AND operation_lease_expires_at IS NOT NULL
               AND operation_lease_expires_at <= ?`
        ).all(now) as StackUpdateRecoveryGenerationRow[];
    }

    public linkStackUpdateRecoveryHealthGate(id: string, healthGateId: string): void {
        this.db.prepare(
            'UPDATE stack_update_recovery_generations SET health_gate_id = ?, updated_at = ? WHERE id = ?'
        ).run(healthGateId, Date.now(), id);
    }

    public setStackUpdateRecoveryGateRetainUntil(id: string, until: number): void {
        this.db.prepare(
            'UPDATE stack_update_recovery_generations SET gate_retain_until = ?, updated_at = ? WHERE id = ?'
        ).run(until, Date.now(), id);
    }

    public listHeldStackUpdateRecoveryImageIds(nodeId: number, now: number): string[] {
        const rows = this.db.prepare(
            `SELECT services_json FROM stack_update_recovery_generations
             WHERE node_id = ?
               AND released_at IS NULL
               AND status IN ('candidate','active','restored_current','recovery_required')
               AND (artifact_expires_at IS NULL OR artifact_expires_at > ? OR gate_retain_until > ? OR is_current = 1)`
        ).all(nodeId, now, now) as Array<{ services_json: string }>;
        const ids = new Set<string>();
        for (const row of rows) {
            const parsed = parseServicesJsonStrict(row.services_json);
            if (!parsed.ok) {
                // Fail closed: corrupt hold metadata must not look like "nothing held".
                throw new Error('Malformed stack recovery services_json while listing held images');
            }
            for (const id of collectImageIds(parsed.services)) {
                ids.add(id);
            }
        }
        return [...ids];
    }

    public deleteStackUpdateRecoveryGenerations(nodeId: number, stackName: string): void {
        this.db.prepare(
            'DELETE FROM stack_update_recovery_generations WHERE node_id = ? AND stack_name = ?'
        ).run(nodeId, stackName);
    }

    public insertCleanupPending(row: StackUpdateCleanupPendingRow): void {
        this.db.prepare(
            `INSERT INTO stack_update_cleanup_pending (
                id, node_id, stack_name, status, target_kind, rollback_tags_json,
                override_paths_json, prune_volumes_requested, required_blueprint_id, created_at, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        ).run(
            row.id, row.node_id, row.stack_name, row.status, row.target_kind,
            row.rollback_tags_json, row.override_paths_json, row.prune_volumes_requested,
            row.required_blueprint_id, row.created_at, row.updated_at,
        );
    }

    public getCleanupPending(id: string): StackUpdateCleanupPendingRow | undefined {
        return this.db.prepare('SELECT * FROM stack_update_cleanup_pending WHERE id = ?').get(id) as StackUpdateCleanupPendingRow | undefined;
    }

    public listReadyCleanupPending(): StackUpdateCleanupPendingRow[] {
        return this.db.prepare(
            "SELECT * FROM stack_update_cleanup_pending WHERE status = 'ready' ORDER BY created_at ASC"
        ).all() as StackUpdateCleanupPendingRow[];
    }

    public listPreparedCleanupPending(): StackUpdateCleanupPendingRow[] {
        return this.db.prepare(
            "SELECT * FROM stack_update_cleanup_pending WHERE status = 'prepared' ORDER BY created_at ASC"
        ).all() as StackUpdateCleanupPendingRow[];
    }

    public updateCleanupPendingStatus(id: string, status: StackUpdateCleanupPendingRow['status']): void {
        this.db.prepare(
            'UPDATE stack_update_cleanup_pending SET status = ?, updated_at = ? WHERE id = ?'
        ).run(status, Date.now(), id);
    }

    public deleteCleanupPending(id: string): void {
        this.db.prepare('DELETE FROM stack_update_cleanup_pending WHERE id = ?').run(id);
    }

    public hasBlockingDeletionIntent(nodeId: number, stackName: string): boolean {
        const row = this.db.prepare(
            `SELECT id FROM stack_update_cleanup_pending
             WHERE node_id = ? AND stack_name = ? AND status IN ('prepared','ready') LIMIT 1`
        ).get(nodeId, stackName);
        return !!row;
    }

    public getPreparedDeletionIntent(nodeId: number, stackName: string): StackUpdateCleanupPendingRow | undefined {
        return this.db.prepare(
            `SELECT * FROM stack_update_cleanup_pending
             WHERE node_id = ? AND stack_name = ? AND status = 'prepared' LIMIT 1`
        ).get(nodeId, stackName) as StackUpdateCleanupPendingRow | undefined;
    }

    public getDeletionIntentById(id: string): StackUpdateCleanupPendingRow | undefined {
        return this.getCleanupPending(id);
    }

    public commitStackDeletionReadyTransaction(intentId: string, nodeId: number, stackName: string): boolean {
        const run = this.db.transaction(() => {
            const intent = this.getCleanupPending(intentId);
            if (!intent || intent.status !== 'prepared') return false;
            if (intent.node_id !== nodeId || intent.stack_name !== stackName) return false;
            this.db.prepare(
                "UPDATE stack_update_cleanup_pending SET status = 'ready', updated_at = ? WHERE id = ? AND status = 'prepared'"
            ).run(Date.now(), intentId);
            this.deleteStackUpdateRecoveryGenerations(nodeId, stackName);
            this.deleteServiceUpdateRecoveries(nodeId, stackName);
            return true;
        });
        return run();
    }

    // --- Notification History ---

    private mapNotificationRow(row: any): NotificationHistory {
        return {
            ...row,
            is_read: row.is_read === 1,
            stack_name: row.stack_name ?? undefined,
            container_name: row.container_name ?? undefined,
            category: row.category ?? undefined,
            actor_username: row.actor_username ?? null,
            suppression_match: row.suppression_match ?? null,
        };
    }

    public getNotificationHistory(nodeId: number, limit = 50, category?: string): NotificationHistory[] {
        const sql = category
            ? 'SELECT * FROM notification_history WHERE node_id = ? AND category = ? ORDER BY timestamp DESC LIMIT ?'
            : 'SELECT * FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT ?';
        const args: (number | string)[] = category ? [nodeId, category, limit] : [nodeId, limit];
        return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any));
    }

    public getNodeStackActivity(nodeId: number, opts: { limit: number }): NotificationHistory[] {
        const categories = [
            'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', 'stack_restarted',
            'image_update_applied', 'update_started', 'health_gate_passed', 'health_gate_failed',
            'network_auto_created', 'rollback_generation_released',
        ];
        const placeholders = categories.map(() => '?').join(', ');
        const sql = `
            SELECT * FROM notification_history
            WHERE node_id = ? AND category IN (${placeholders})
            ORDER BY timestamp DESC, id DESC
            LIMIT ?
        `;
        return (this.db.prepare(sql).all(nodeId, ...categories, opts.limit) as unknown[])
            .map(row => this.mapNotificationRow(row as any));
    }

    public addNotificationHistory(nodeId: number, notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
        const dedupeKey = notification.dedupe_key ?? null;
        const stmt = this.db.prepare(
            `INSERT INTO notification_history (
                node_id, level, message, timestamp, is_read, stack_name, container_name,
                category, actor_username, gitops_operation_id, dedupe_key
            ) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING`
        );
        const result = stmt.run(
            nodeId,
            notification.level,
            notification.message,
            notification.timestamp,
            notification.stack_name ?? null,
            notification.container_name ?? null,
            notification.category ?? null,
            notification.actor_username ?? null,
            notification.gitops_operation_id ?? null,
            dedupeKey,
        );

        // A repair replaying a settled GitOps attempt must not create a
        // duplicate notification; the conflict is not an error, it is proof
        // this exact attempt was already reported.
        if (result.changes === 0 && dedupeKey !== null) {
            const existing = this.db.prepare(
                'SELECT * FROM notification_history WHERE dedupe_key = ?',
            ).get(dedupeKey);
            return this.mapNotificationRow(existing);
        }

        return {
            id: result.lastInsertRowid as number,
            level: notification.level,
            category: notification.category,
            message: notification.message,
            timestamp: notification.timestamp,
            is_read: false,
            stack_name: notification.stack_name,
            container_name: notification.container_name,
            actor_username: notification.actor_username,
            gitops_operation_id: notification.gitops_operation_id,
            dedupe_key: dedupeKey,
        };
    }

    public clearSelfContainerNotificationRouting(
        nodeId: number,
        self: { containerName?: string | null; composeProjectName?: string | null },
    ): number {
        const containerName = self.containerName?.trim() || null;
        const composeProjectName = self.composeProjectName?.trim() || null;
        if (!containerName && !composeProjectName) return 0;

        const predicates: string[] = [];
        const args: (number | string)[] = [nodeId];
        if (containerName) {
            predicates.push('container_name = ?');
            args.push(containerName);
        }
        if (composeProjectName) {
            predicates.push('(container_name IS NULL AND stack_name = ?)');
            args.push(composeProjectName);
        }

        const result = this.db.prepare(`
            UPDATE notification_history
               SET stack_name = NULL,
                   container_name = NULL
             WHERE node_id = ?
               AND actor_username = 'system:docker-events'
               AND category = 'monitor_alert'
               AND (stack_name IS NOT NULL OR container_name IS NOT NULL)
               AND (${predicates.join(' OR ')})
        `).run(...args);
        return result.changes;
    }

    public getStackActivity(nodeId: number, stackName: string, opts: { limit: number; before?: number; beforeId?: number }): NotificationHistory[] {
        // Composite (timestamp, id) cursor: pure timestamp pagination drops rows
        // on same-millisecond bursts (Docker events from one compose up).
        let sql: string;
        let args: (number | string)[];
        if (opts.before !== undefined && opts.beforeId !== undefined) {
            sql = 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? AND (timestamp < ? OR (timestamp = ? AND id < ?)) ORDER BY timestamp DESC, id DESC LIMIT ?';
            args = [nodeId, stackName, opts.before, opts.before, opts.beforeId, opts.limit];
        } else if (opts.before !== undefined) {
            sql = 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? AND timestamp < ? ORDER BY timestamp DESC, id DESC LIMIT ?';
            args = [nodeId, stackName, opts.before, opts.limit];
        } else {
            sql = 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? ORDER BY timestamp DESC, id DESC LIMIT ?';
            args = [nodeId, stackName, opts.limit];
        }
        return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any));
    }

    public markAllNotificationsRead(nodeId: number): void {
        const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1 WHERE node_id = ?');
        stmt.run(nodeId);
    }

    public deleteNotification(nodeId: number, id: number): void {
        const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ? AND id = ?');
        stmt.run(nodeId, id);
    }

    public deleteAllNotifications(nodeId: number): void {
        const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ?');
        stmt.run(nodeId);
    }

    /** Purge stack-scoped notification history when a stack is deleted. */
    public deleteNotificationsForStack(nodeId: number, stackName: string): number {
        const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ? AND stack_name = ?');
        return stmt.run(nodeId, stackName).changes;
    }

    public updateNotificationDispatchError(id: number, error: string): void {
        this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
    }

    public updateNotificationSuppressionMatch(
        id: number,
        snapshot: { rules: { id: number; name: string }[]; bellSuppressed: boolean; externalSuppressed: boolean },
    ): void {
        this.db.prepare('UPDATE notification_history SET suppression_match = ? WHERE id = ?').run(JSON.stringify(snapshot), id);
    }

    public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
        const since = Date.now() - days * 86400 * 1000;
        return this.db.prepare(`
            SELECT
              stack_name AS stackName,
              SUM(CASE WHEN category = 'deploy_failure'     THEN 1 ELSE 0 END) AS crash,
              SUM(CASE WHEN category = 'autoheal_triggered' THEN 1 ELSE 0 END) AS autoheal,
              SUM(CASE WHEN category = 'stack_restarted'    THEN 1 ELSE 0 END) AS manual,
              COUNT(*) AS total
            FROM notification_history
            WHERE node_id = ?
              AND timestamp >= ?
              AND category IN ('deploy_failure', 'autoheal_triggered', 'stack_restarted')
              AND stack_name IS NOT NULL
            GROUP BY stack_name
            ORDER BY total DESC
        `).all(nodeId, since) as StackRestartSummary[];
    }

    // --- Container Metrics ---

    public addContainerMetric(metric: Omit<any, 'id'>): void {
        const stmt = this.db.prepare(
            'INSERT INTO container_metrics (container_id, stack_name, cpu_percent, memory_mb, net_rx_mb, net_tx_mb, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)'
        );
        stmt.run(metric.container_id, metric.stack_name, metric.cpu_percent, metric.memory_mb, metric.net_rx_mb, metric.net_tx_mb, metric.timestamp);
    }

    public bulkAddContainerMetrics(metrics: Omit<any, 'id'>[]): void {
        const stmt = this.db.prepare(
            'INSERT INTO container_metrics (container_id, stack_name, cpu_percent, memory_mb, net_rx_mb, net_tx_mb, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)'
        );
        const insertAll = this.db.transaction((items: Omit<any, 'id'>[]) => {
            for (const m of items) {
                stmt.run(m.container_id, m.stack_name, m.cpu_percent, m.memory_mb, m.net_rx_mb, m.net_tx_mb, m.timestamp);
            }
        });
        insertAll(metrics);
    }

    public getContainerMetrics(hoursLookback = 24): any[] {
        const cutoff = Date.now() - (hoursLookback * 60 * 60 * 1000);
        // Aggregate into 5-minute buckets (300000ms) to keep response size bounded.
        // With 24h lookback that's ~288 buckets per container instead of ~1440.
        const bucketMs = 300000;
        const stmt = this.db.prepare(`
            SELECT
              container_id,
              stack_name,
              AVG(cpu_percent) as cpu_percent,
              AVG(memory_mb) as memory_mb,
              MAX(net_rx_mb) as net_rx_mb,
              MAX(net_tx_mb) as net_tx_mb,
              (timestamp / ${bucketMs}) * ${bucketMs} as timestamp
            FROM container_metrics
            WHERE timestamp >= ?
            GROUP BY container_id, stack_name, (timestamp / ${bucketMs})
            ORDER BY timestamp ASC
        `);
        return stmt.all(cutoff);
    }

    public cleanupOldMetrics(hoursToKeep = 24): void {
        const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000);
        const stmt = this.db.prepare('DELETE FROM container_metrics WHERE timestamp < ?');
        stmt.run(cutoff);
    }

    public cleanupOldNotifications(daysToKeep = 30, opts: { perStackCap?: number; perNodeUnattachedCap?: number } = {}): { ttl: number; perStack: number; perNode: number } {
        const perStackCap = opts.perStackCap ?? 500;
        const perNodeUnattachedCap = opts.perNodeUnattachedCap ?? 1000;
        const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
        const ttlInfo = this.db.prepare('DELETE FROM notification_history WHERE timestamp < ?').run(cutoff);

        const deleteById = this.db.prepare('DELETE FROM notification_history WHERE id = ?');
        const deleteMany = this.db.transaction((ids: number[]) => {
            for (const id of ids) deleteById.run(id);
        });

        // Per (node_id, stack_name) cap so a chatty stack cannot evict a quieter stack's history.
        const stackOverflow = this.db.prepare(`
            SELECT id FROM (
                SELECT id, ROW_NUMBER() OVER (
                    PARTITION BY node_id, stack_name
                    ORDER BY timestamp DESC, id DESC
                ) AS rn
                FROM notification_history
                WHERE stack_name IS NOT NULL
            )
            WHERE rn > ?
        `).all(perStackCap) as { id: number }[];
        if (stackOverflow.length > 0) deleteMany(stackOverflow.map(r => r.id));

        // Unattached system events have no stack to scope by, so they cannot share the per-stack quota; cap them per-node separately.
        const unattachedOverflow = this.db.prepare(`
            SELECT id FROM (
                SELECT id, ROW_NUMBER() OVER (
                    PARTITION BY node_id
                    ORDER BY timestamp DESC, id DESC
                ) AS rn
                FROM notification_history
                WHERE stack_name IS NULL
            )
            WHERE rn > ?
        `).all(perNodeUnattachedCap) as { id: number }[];
        if (unattachedOverflow.length > 0) deleteMany(unattachedOverflow.map(r => r.id));

        return {
            ttl: Number(ttlInfo.changes ?? 0),
            perStack: stackOverflow.length,
            perNode: unattachedOverflow.length,
        };
    }

    // --- Nodes ---

    private decryptNodeRow(row: any): Node {
        const crypto = CryptoService.getInstance();
        return {
            ...row,
            is_default: row.is_default === 1,
            mode: (row.mode === 'pilot_agent' ? 'pilot_agent' : 'proxy') as NodeMode,
            api_token: row.api_token ? crypto.decrypt(row.api_token) : '',
            pilot_last_seen: row.pilot_last_seen ?? null,
            pilot_agent_version: row.pilot_agent_version ?? null,
            last_successful_contact: row.last_successful_contact ?? null,
            cordoned: row.cordoned === 1,
            cordoned_at: row.cordoned_at ?? null,
            cordoned_reason: row.cordoned_reason ?? null,
        };
    }

    private static readonly NODE_COLUMNS =
        'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact, cordoned, cordoned_at, cordoned_reason';

    public getNodes(): Node[] {
        const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes ORDER BY is_default DESC, name ASC`);
        return stmt.all().map((row: any) => this.decryptNodeRow(row));
    }

    public getNode(id: number): Node | undefined {
        const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes WHERE id = ?`);
        const row = stmt.get(id) as any;
        if (!row) return undefined;
        return this.decryptNodeRow(row);
    }

    public getDefaultNode(): Node | undefined {
        const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes WHERE is_default = 1 LIMIT 1`);
        const row = stmt.get() as any;
        if (!row) return undefined;
        return this.decryptNodeRow(row);
    }

    public addNode(node: Omit<Node, 'id' | 'status' | 'created_at' | 'mode' | 'cordoned' | 'cordoned_at' | 'cordoned_reason'> & { mode?: NodeMode }): number {
        const isLocal = node.type === 'local';
        // Guard against duplicate local nodes before any mutation so a throw
        // cannot leave the table in a broken state (e.g. default cleared).
        if (isLocal && this.getLocalNodeCount() > 0) {
            throw new Error('A local node already exists. Only one local node is allowed per instance.');
        }

        // When creating a local node and none exists (zero-local recovery),
        // make it the default so documentation remains accurate.
        const shouldBeDefault = node.is_default || isLocal;

        const runInsert = () => {
            if (shouldBeDefault) {
                this.db.prepare('UPDATE nodes SET is_default = 0').run();
            }
            const crypto = CryptoService.getInstance();
            const stmt = this.db.prepare(
                'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
            );
            return stmt.run(
                node.name,
                node.type,
                node.compose_dir || '/app/compose',
                shouldBeDefault ? 1 : 0,
                'unknown',
                Date.now(),
                node.api_url || '',
                node.api_token ? crypto.encrypt(node.api_token) : '',
                node.mode || 'proxy'
            );
        };
        const result = this.db.transaction(runInsert)();
        return result.lastInsertRowid as number;
    }

    public updateNode(id: number, updates: Partial<Omit<Node, 'id' | 'created_at'>>): void {
        const node = this.getNode(id);
        if (!node) throw new Error(`Node with id ${id} not found`);

        if (updates.type !== undefined && updates.type !== node.type) {
            throw new Error('Node type cannot be changed after creation.');
        }

        if (updates.is_default) {
            this.db.prepare('UPDATE nodes SET is_default = 0').run();
        }

        const fields: string[] = [];
        const values: any[] = [];

        if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
        if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
        if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); }
        if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); }
        if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
        if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
        if (updates.api_token !== undefined) {
            fields.push('api_token = ?');
            values.push(updates.api_token ? CryptoService.getInstance().encrypt(updates.api_token) : '');
        }
        if (updates.mode !== undefined) { fields.push('mode = ?'); values.push(updates.mode); }
        if (updates.pilot_last_seen !== undefined) { fields.push('pilot_last_seen = ?'); values.push(updates.pilot_last_seen); }
        if (updates.pilot_agent_version !== undefined) { fields.push('pilot_agent_version = ?'); values.push(updates.pilot_agent_version); }

        if (fields.length === 0) return;

        values.push(id);
        this.db.prepare(`UPDATE nodes SET ${fields.join(', ')} WHERE id = ?`).run(...values);
    }

    /**
     * Delete a node row and related per-node state.
     * For a non-default local-socket node, pass localCleanup so a ready tombstone
     * is inserted in the same transaction (caller enumerates tags/paths first).
     * Remote hub node records must omit localCleanup (no Docker tombstone).
     */
    public deleteNode(
        id: number,
        localCleanup?: { tombstoneId: string; tags: string[]; overridePaths: string[] },
    ): void {
        const node = this.getNode(id);
        // Protect the last local node regardless of is_default flag.
        if (node && node.type === 'local' && this.getLocalNodeCount() <= 1) {
            throw new Error('Cannot delete the only local node. Each Sencho instance must retain its local identity.');
        }
        if (node?.is_default) {
            throw new Error('Cannot delete the default node');
        }
        if (localCleanup && node?.type !== 'local') {
            throw new Error('Local cleanup tombstones are only valid for local-socket nodes');
        }
        this.db.transaction(() => {
            if (localCleanup && node?.type === 'local') {
                const now = Date.now();
                this.insertCleanupPending({
                    id: localCleanup.tombstoneId,
                    node_id: id,
                    stack_name: null,
                    status: 'ready',
                    target_kind: 'local_socket',
                    rollback_tags_json: JSON.stringify(localCleanup.tags),
                    override_paths_json: JSON.stringify(localCleanup.overridePaths),
                    prune_volumes_requested: 0,
                    required_blueprint_id: null,
                    created_at: now,
                    updated_at: now,
                });
            }
            this.db.prepare('DELETE FROM scheduled_task_runs WHERE task_id IN (SELECT id FROM scheduled_tasks WHERE node_id = ?)').run(id);
            this.db.prepare('DELETE FROM scheduled_tasks WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id);
            this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM preflight_acknowledgements WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM stack_exposure WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM stack_update_recovery_generations WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ?').run(id);
            this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
            this.deleteRoleAssignmentsByResource('node', String(id));
            this.deleteRoleAssignmentsByStackNode(id);
            this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
            this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(id);
            this.db.prepare(
                'DELETE FROM notification_suppression_pending_retractions WHERE node_id = ?',
            ).run(id);
            this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
        })();
    }

    public updateNodeStatus(id: number, status: 'online' | 'offline' | 'unknown'): void {
        this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
    }

    public setNodeCordoned(id: number, cordoned: boolean, reason: string | null): Node | undefined {
        if (cordoned) {
            this.db.prepare(
                'UPDATE nodes SET cordoned = 1, cordoned_at = ?, cordoned_reason = ? WHERE id = ?'
            ).run(Date.now(), reason, id);
        } else {
            this.db.prepare(
                'UPDATE nodes SET cordoned = 0, cordoned_at = NULL, cordoned_reason = NULL WHERE id = ?'
            ).run(id);
        }
        return this.getNode(id);
    }

    public setBlueprintPinnedNode(blueprintId: number, nodeId: number | null): Blueprint | undefined {
        this.db.prepare(
            `UPDATE blueprints SET pinned_node_id = ?, updated_at = ?,
                approval_status = 'pending',
                approved_intent_fingerprint = NULL,
                approved_blast_json = NULL,
                approved_at = NULL,
                approved_by = NULL
             WHERE id = ?`,
        ).run(nodeId, Date.now(), blueprintId);
        return this.getBlueprint(blueprintId);
    }

    /** Persist approval without bumping operational updated_at. */
    public setBlueprintApproval(
        blueprintId: number,
        input: {
            intentFingerprint: string;
            blastJson: string;
            approvedBy: string | null;
        },
    ): Blueprint | undefined {
        this.db.prepare(
            `UPDATE blueprints SET
                approval_status = 'approved',
                approved_intent_fingerprint = ?,
                approved_blast_json = ?,
                approved_at = ?,
                approved_by = ?
             WHERE id = ?`,
        ).run(input.intentFingerprint, input.blastJson, Date.now(), input.approvedBy, blueprintId);
        return this.getBlueprint(blueprintId);
    }

    public clearBlueprintApproval(blueprintId: number): void {
        this.db.prepare(
            `UPDATE blueprints SET
                approval_status = 'pending',
                approved_intent_fingerprint = NULL,
                approved_blast_json = NULL,
                approved_at = NULL,
                approved_by = NULL
             WHERE id = ?`,
        ).run(blueprintId);
    }

    /** True when any deployment row is not withdrawn (blocks rename). */
    public hasNonWithdrawnBlueprintDeployments(blueprintId: number): boolean {
        const row = this.db.prepare(
            `SELECT 1 AS ok FROM blueprint_deployments
             WHERE blueprint_id = ? AND status != 'withdrawn' LIMIT 1`,
        ).get(blueprintId) as { ok: number } | undefined;
        return !!row;
    }

    public updateNodeLastContact(nodeId: number): void {
        this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?')
            .run(Math.floor(Date.now() / 1000), nodeId);
    }

    // --- Pilot enrollments ---

    public getPilotEnrollment(nodeId: number): PilotEnrollment | undefined {
        const row = this.db.prepare(
            'SELECT node_id, token_hash, expires_at, used_at FROM pilot_enrollments WHERE node_id = ?'
        ).get(nodeId) as PilotEnrollment | undefined;
        return row;
    }

    public createPilotEnrollment(nodeId: number, tokenHash: string, expiresAt: number): void {
        this.db.prepare(
            `INSERT INTO pilot_enrollments (node_id, token_hash, expires_at, used_at)
             VALUES (?, ?, ?, NULL)
             ON CONFLICT(node_id) DO UPDATE SET
                token_hash = excluded.token_hash,
                expires_at = excluded.expires_at,
                used_at = NULL`
        ).run(nodeId, tokenHash, expiresAt);
    }

    public consumePilotEnrollment(tokenHash: string): PilotEnrollment | undefined {
        const now = Date.now();
        return this.db.transaction(() => {
            const row = this.db.prepare(
                `SELECT node_id, token_hash, expires_at, used_at FROM pilot_enrollments
                 WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`
            ).get(tokenHash, now) as PilotEnrollment | undefined;
            if (!row) return undefined;
            this.db.prepare('UPDATE pilot_enrollments SET used_at = ? WHERE node_id = ?').run(now, row.node_id);
            return { ...row, used_at: now };
        })();
    }

    public deletePilotEnrollment(nodeId: number): void {
        this.db.prepare('DELETE FROM pilot_enrollments WHERE node_id = ?').run(nodeId);
    }

    /**
     * One-time consume for a console_session JWT id. Inserts the jti on first
     * use; returns false when the jti was already recorded (replay).
     */
    public consumeConsoleSessionJti(jti: string, expiresAtMs: number): boolean {
        const now = Date.now();
        return this.db.transaction(() => {
            this.db.prepare('DELETE FROM console_session_jtis WHERE expires_at < ?').run(now);
            const result = this.db.prepare(
                'INSERT OR IGNORE INTO console_session_jtis (jti, used_at, expires_at) VALUES (?, ?, ?)',
            ).run(jti, now, expiresAtMs);
            return result.changes > 0;
        })();
    }

    // --- Stack Update Status ---

    /**
     * `services` is omitted by legacy (non-service-aware) callers; omitting it
     * leaves the stack's existing services_json untouched (COALESCE against the
     * current row) rather than erasing a per-service breakdown that a model-aware
     * caller persisted on a previous check.
     */
    public upsertStackUpdateStatus(
        nodeId: number,
        stackName: string,
        hasUpdate: boolean,
        checkedAt: number,
        checkStatus: StackCheckStatus = 'ok',
        lastError: string | null = null,
        services?: StackServiceStatus[],
        generation = 0,
    ): void {
        const servicesJson = services !== undefined ? stringifyServicesJson(services, generation) : null;
        this.db.prepare(
            `INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at, services_json)
             VALUES (?, ?, ?, ?, ?, ?, ?)
             ON CONFLICT(node_id, stack_name) DO UPDATE SET
               has_update = excluded.has_update,
               check_status = excluded.check_status,
               last_error = excluded.last_error,
               checked_at = excluded.checked_at,
               services_json = COALESCE(excluded.services_json, stack_update_status.services_json)`
        ).run(nodeId, stackName, hasUpdate ? 1 : 0, checkStatus, lastError, checkedAt, servicesJson);
    }

    /**
     * Record a fully-failed check (no checkable image could be reached) without
     * touching has_update, so a transient registry outage cannot erase a real
     * update or flap the notification state. On an existing row it updates only
     * check_status / last_error / checked_at, leaving has_update intact; a
     * first-ever failed check inserts a row with has_update = 0 so the stack
     * still appears with its failure reason.
     */
    public recordStackCheckFailure(
        nodeId: number,
        stackName: string,
        lastError: string,
        checkedAt: number,
        services?: StackServiceStatus[],
        generation = 0,
    ): void {
        const servicesJson = services !== undefined ? stringifyServicesJson(services, generation) : null;
        this.db.prepare(
            `INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at, services_json)
             VALUES (?, ?, 0, 'failed', ?, ?, ?)
             ON CONFLICT(node_id, stack_name) DO UPDATE SET
               check_status = 'failed',
               last_error = excluded.last_error,
               checked_at = excluded.checked_at,
               services_json = COALESCE(excluded.services_json, stack_update_status.services_json)`
        ).run(nodeId, stackName, lastError, checkedAt, servicesJson);
    }

    /**
     * Raw has_update map for scanner retention and notification transitions.
     * Ignores check_status: a partial/failed row with has_update=1 stays true
     * so ImageUpdateService can preserve sticky state across incomplete runs.
     * API/Fleet consumers that need "confirmed update" must use
     * getConfirmedStackUpdateStatus instead.
     */
    public getStackUpdateStatus(nodeId?: number): Record<string, boolean> {
        const rows = nodeId !== undefined
            ? this.db.prepare('SELECT stack_name, has_update FROM stack_update_status WHERE node_id = ?').all(nodeId) as Array<{ stack_name: string; has_update: number }>
            : this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as Array<{ stack_name: string; has_update: number }>;
        const result: Record<string, boolean> = {};
        for (const row of rows) {
            result[row.stack_name] = row.has_update === 1;
        }
        return result;
    }

    /**
     * Confirmed-update projection for GET /api/image-updates and Fleet local
     * aggregation. True only when has_update=1 and the latest check completed
     * successfully (check_status='ok'). Partial/failed retained rows are false.
     */
    public getConfirmedStackUpdateStatus(nodeId?: number): Record<string, boolean> {
        const rows = nodeId !== undefined
            ? this.db.prepare(
                `SELECT stack_name, has_update, check_status FROM stack_update_status WHERE node_id = ?`
            ).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null }>
            : this.db.prepare(
                `SELECT stack_name, has_update, check_status FROM stack_update_status`
            ).all() as Array<{ stack_name: string; has_update: number; check_status: string | null }>;
        const result: Record<string, boolean> = {};
        for (const row of rows) {
            // Match getNodeUpdateSummary / frontend isConfirmedImageUpdate:
            // null check_status is treated as ok (legacy rows).
            result[row.stack_name] = row.has_update === 1 && (row.check_status ?? 'ok') === 'ok';
        }
        return result;
    }

    /**
     * Rich per-stack update status (hasUpdate + check outcome + reason) for the
     * sidebar/readiness UI. Confirmed boolean maps use getConfirmedStackUpdateStatus;
     * raw prior state for the scanner stays on getStackUpdateStatus.
     */
    public getStackUpdateDetail(nodeId: number): Record<string, StackUpdateDetail> {
        const rows = this.db.prepare(
            'SELECT stack_name, has_update, check_status, last_error, checked_at, services_json FROM stack_update_status WHERE node_id = ?'
        ).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null; last_error: string | null; checked_at: number; services_json: string | null }>;
        const result: Record<string, StackUpdateDetail> = {};
        for (const row of rows) {
            const services = parseServicesJson(row.services_json);
            result[row.stack_name] = {
                hasUpdate: row.has_update === 1,
                checkStatus: (row.check_status === 'failed' || row.check_status === 'partial') ? row.check_status : 'ok',
                lastError: row.last_error,
                checkedAt: row.checked_at,
                ...(services.length > 0 ? { services } : {}),
            };
        }
        return result;
    }

    /**
     * Prior per-service breakdown for a single stack, used by ImageUpdateService
     * to look up each service's last-known hasUpdate before reducing a fresh
     * check (so a preserved value survives a partial/failed re-check). Corrupt
     * or missing JSON yields an empty list.
     */
    public getStackServicesJson(nodeId: number, stackName: string): StackServiceStatus[] {
        const row = this.db.prepare(
            'SELECT services_json FROM stack_update_status WHERE node_id = ? AND stack_name = ?'
        ).get(nodeId, stackName) as { services_json: string | null } | undefined;
        return parseServicesJson(row?.services_json);
    }

    /**
     * Scanner write generation stored with services_json for this stack.
     * Used by preview reconcile to skip clearing a row written after the
     * preview observation watermark. Returns 0 when missing or unreadable.
     */
    public getStackUpdateWriteGeneration(nodeId: number, stackName: string): number {
        const row = this.db.prepare(
            'SELECT services_json FROM stack_update_status WHERE node_id = ? AND stack_name = ?'
        ).get(nodeId, stackName) as { services_json: string | null } | undefined;
        return parseServicesJsonGeneration(row?.services_json);
    }

    /** Deletes the full update row (aggregate + services_json). Returns deleted row count. */
    public clearStackUpdateStatus(nodeId: number, stackName: string): number {
        const result = this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
        return result.changes;
    }

    /** Deletes every update row for a node. Returns deleted row count. */
    public clearAllStackUpdateStatus(nodeId: number): number {
        const result = this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(nodeId);
        return result.changes;
    }

    // --- Stack Scan Attempts ---
    //
    // Tracks the latest post-deploy scan attempt per (nodeId, stackName) so
    // operators can see when a scan was skipped or failed without scrolling
    // logs. One row per stack; the table is overwritten on every attempt.

    public recordStackScanAttempt(
        nodeId: number,
        stackName: string,
        status: 'ok' | 'partial' | 'failed' | 'skipped',
        errorMessage: string | null,
    ): void {
        this.db.prepare(
            `INSERT INTO stack_scan_attempts (node_id, stack_name, status, attempted_at, error_message)
             VALUES (?, ?, ?, ?, ?)
             ON CONFLICT(node_id, stack_name) DO UPDATE SET
               status = excluded.status,
               attempted_at = excluded.attempted_at,
               error_message = excluded.error_message`
        ).run(nodeId, stackName, status, Date.now(), errorMessage);
    }

    public getStackScanAttempt(nodeId: number, stackName: string): {
        status: string;
        attempted_at: number;
        error_message: string | null;
    } | null {
        const row = this.db.prepare(
            'SELECT status, attempted_at, error_message FROM stack_scan_attempts WHERE node_id = ? AND stack_name = ?'
        ).get(nodeId, stackName) as { status: string; attempted_at: number; error_message: string | null } | undefined;
        return row ?? null;
    }

    public clearStackScanAttempts(nodeId: number, stackName: string): void {
        this.db.prepare('DELETE FROM stack_scan_attempts WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
    }

    public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> {
        return this.db.prepare(
            `SELECT node_id, SUM(has_update) as stacks_with_updates
             FROM stack_update_status
             WHERE has_update = 1 AND COALESCE(check_status, 'ok') = 'ok'
             GROUP BY node_id`
        ).all() as Array<{ node_id: number; stacks_with_updates: number }>;
    }

    public getNodeSchedulingSummary(): Array<{
        node_id: number;
        active_tasks: number;
        auto_update_enabled: number;
        next_run_at: number | null;
    }> {
        return this.db.prepare(`
            SELECT
                node_id,
                COUNT(*) as active_tasks,
                MAX(CASE WHEN action = 'update' AND enabled = 1 THEN 1 ELSE 0 END) as auto_update_enabled,
                MIN(next_run_at) as next_run_at
            FROM scheduled_tasks
            WHERE enabled = 1 AND node_id IS NOT NULL
            GROUP BY node_id
        `).all() as Array<{ node_id: number; active_tasks: number; auto_update_enabled: number; next_run_at: number | null }>;
    }

    // --- Webhooks ---

    public getWebhooks(): Webhook[] {
        return this.db.prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all().map((row: any) => ({
            ...row,
            node_id: Number(row.node_id ?? this.getDefaultNode()?.id ?? 1),
            enabled: row.enabled === 1,
        }));
    }

    public getWebhook(id: number): Webhook | undefined {
        const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any;
        if (!row) return undefined;
        return {
            ...row,
            node_id: Number(row.node_id ?? this.getDefaultNode()?.id ?? 1),
            enabled: row.enabled === 1,
        };
    }

    public addWebhook(webhook: Omit<Webhook, 'id' | 'created_at' | 'updated_at'>): number {
        const now = Date.now();
        const result = this.db.prepare(
            'INSERT INTO webhooks (node_id, name, stack_name, action, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
        ).run(webhook.node_id, webhook.name, webhook.stack_name, webhook.action, webhook.secret, webhook.enabled ? 1 : 0, now, now);
        return result.lastInsertRowid as number;
    }

    public updateWebhook(id: number, updates: Partial<Pick<Webhook, 'node_id' | 'name' | 'stack_name' | 'action' | 'enabled'>>): void {
        const fields: string[] = [];
        const values: (string | number)[] = [];

        if (updates.node_id !== undefined) { fields.push('node_id = ?'); values.push(updates.node_id); }
        if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
        if (updates.stack_name !== undefined) { fields.push('stack_name = ?'); values.push(updates.stack_name); }
        if (updates.action !== undefined) { fields.push('action = ?'); values.push(updates.action); }
        if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }

        if (fields.length === 0) return;

        fields.push('updated_at = ?');
        values.push(Date.now());
        values.push(id);
        this.db.prepare(`UPDATE webhooks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
    }

    public deleteWebhook(id: number): void {
        this.db.prepare('DELETE FROM webhooks WHERE id = ?').run(id);
    }

    // --- Webhook Executions ---

    public getWebhookExecutions(webhookId: number, limit = 20): WebhookExecution[] {
        return this.db.prepare(
            'SELECT * FROM webhook_executions WHERE webhook_id = ? ORDER BY executed_at DESC LIMIT ?'
        ).all(webhookId, limit) as WebhookExecution[];
    }

    public addWebhookExecution(execution: Omit<WebhookExecution, 'id'>): number {
        const result = this.db.prepare(
            'INSERT INTO webhook_executions (webhook_id, action, status, trigger_source, duration_ms, error, executed_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
        ).run(execution.webhook_id, execution.action, execution.status, execution.trigger_source, execution.duration_ms, execution.error, execution.executed_at);

        // Keep only last 100 executions per webhook
        this.db.prepare(
            'DELETE FROM webhook_executions WHERE webhook_id = ? AND id NOT IN (SELECT id FROM webhook_executions WHERE webhook_id = ? ORDER BY executed_at DESC LIMIT 100)'
        ).run(execution.webhook_id, execution.webhook_id);

        return result.lastInsertRowid as number;
    }

    // --- Users ---

    public getUsers(): Omit<User, 'password_hash'>[] {
        return this.db.prepare('SELECT id, username, role, auth_provider, provider_id, email, created_at, updated_at FROM users ORDER BY created_at ASC').all() as Omit<User, 'password_hash'>[];
    }

    public getUser(id: number): User | undefined {
        return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
    }

    public getUserByUsername(username: string): User | undefined {
        return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
    }

    public getUserById(id: number): User | undefined {
        return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
    }

    public getUserByProviderIdentity(authProvider: string, providerId: string): User | undefined {
        return this.db.prepare('SELECT * FROM users WHERE auth_provider = ? AND provider_id = ?').get(authProvider, providerId) as User | undefined;
    }

    public addUser(user: { username: string; password_hash: string; role: UserRole; auth_provider?: AuthProvider; provider_id?: string | null; email?: string | null }): number {
        const now = Date.now();
        const result = this.db.prepare(
            'INSERT INTO users (username, password_hash, role, auth_provider, provider_id, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
        ).run(user.username, user.password_hash, user.role, user.auth_provider ?? 'local', user.provider_id ?? null, user.email ?? null, now, now);
        return result.lastInsertRowid as number;
    }

    public updateUser(id: number, updates: Partial<{ username: string; password_hash: string; role: string; email: string }>): void {
        const fields: string[] = [];
        const values: (string | number)[] = [];

        if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
        if (updates.password_hash !== undefined) { fields.push('password_hash = ?'); values.push(updates.password_hash); }
        if (updates.role !== undefined) { fields.push('role = ?'); values.push(updates.role); }
        if (updates.email !== undefined) { fields.push('email = ?'); values.push(updates.email); }

        if (fields.length === 0) return;

        fields.push('updated_at = ?');
        values.push(Date.now());
        values.push(id);
        this.db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
    }

    public deleteUser(id: number): void {
        this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
    }

    /**
     * Atomically apply `updates` unless doing so would demote the last remaining
     * admin. Returns false (nothing written) when the change would leave zero
     * admins, true otherwise. The current-role read, the admin count, and the
     * write run in a single transaction so a concurrent demote or delete of the
     * other admin cannot race the count to zero.
     */
    public updateUserIfNotLastAdmin(id: number, updates: Partial<{ username: string; password_hash: string; role: string; email: string }>): boolean {
        return this.transaction(() => {
            if (updates.role !== undefined && updates.role !== 'admin') {
                const current = this.db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string } | undefined;
                if (current?.role === 'admin' && this.getAdminCount() <= 1) return false;
            }
            this.updateUser(id, updates);
            return true;
        });
    }

    /**
     * Atomically delete the user unless it is the last remaining admin. Returns
     * false (nothing deleted) in that case, true otherwise. Same single-
     * transaction guard as {@link updateUserIfNotLastAdmin}.
     */
    public deleteUserIfNotLastAdmin(id: number): boolean {
        return this.transaction(() => {
            const current = this.db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string } | undefined;
            if (current?.role === 'admin' && this.getAdminCount() <= 1) return false;
            this.deleteUser(id);
            return true;
        });
    }

    public getUserCount(): number {
        return (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
    }

    public getAdminCount(): number {
        return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number })?.count || 0;
    }

    public bumpTokenVersion(userId: number): void {
        this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ? WHERE id = ?').run(Date.now(), userId);
    }

    /**
     * Invalidate every active session by bumping every user's token_version in
     * one statement. Returns the number of users affected. Used by the
     * emergency `clear-sessions` CLI when a stolen cookie or a wedged login
     * state needs a clean sign-out of every user on this node.
     */
    public bumpAllTokenVersions(): number {
        const result = this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ?').run(Date.now());
        return result.changes;
    }

    // --- User MFA ---

    public getUserMfa(userId: number): UserMfa | undefined {
        return this.db.prepare('SELECT * FROM user_mfa WHERE user_id = ?').get(userId) as UserMfa | undefined;
    }

    /** Count of users with a completed (enabled) two-factor enrolment. */
    public getMfaEnrolledCount(): number {
        return (this.db.prepare('SELECT COUNT(*) as count FROM user_mfa WHERE enabled = 1').get() as { count: number })?.count || 0;
    }

    /**
     * Create or merge a user_mfa row. Any field left undefined on the update
     * object is preserved. Boolean flags are normalized to 0/1.
     */
    public upsertUserMfa(userId: number, updates: UserMfaUpdate): void {
        const now = Date.now();
        const existing = this.getUserMfa(userId);

        if (!existing) {
            this.db.prepare(
                `INSERT INTO user_mfa
                  (user_id, enabled, totp_secret_encrypted, backup_codes_json, sso_enforce_mfa,
                   failed_attempts, locked_until, created_at, updated_at)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
            ).run(
                userId,
                updates.enabled ? 1 : 0,
                updates.totp_secret_encrypted ?? null,
                updates.backup_codes_json ?? null,
                updates.sso_enforce_mfa ? 1 : 0,
                updates.failed_attempts ?? 0,
                updates.locked_until ?? null,
                now,
                now,
            );
            return;
        }

        const fields: string[] = [];
        const values: (string | number | null)[] = [];
        if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
        if (updates.totp_secret_encrypted !== undefined) { fields.push('totp_secret_encrypted = ?'); values.push(updates.totp_secret_encrypted); }
        if (updates.backup_codes_json !== undefined) { fields.push('backup_codes_json = ?'); values.push(updates.backup_codes_json); }
        if (updates.sso_enforce_mfa !== undefined) { fields.push('sso_enforce_mfa = ?'); values.push(updates.sso_enforce_mfa ? 1 : 0); }
        if (updates.failed_attempts !== undefined) { fields.push('failed_attempts = ?'); values.push(updates.failed_attempts); }
        if (updates.locked_until !== undefined) { fields.push('locked_until = ?'); values.push(updates.locked_until); }

        if (fields.length === 0) return;

        fields.push('updated_at = ?');
        values.push(now);
        values.push(userId);
        this.db.prepare(`UPDATE user_mfa SET ${fields.join(', ')} WHERE user_id = ?`).run(...values);
    }

    public deleteUserMfa(userId: number): void {
        this.db.prepare('DELETE FROM user_mfa WHERE user_id = ?').run(userId);
        this.db.prepare('DELETE FROM mfa_used_tokens WHERE user_id = ?').run(userId);
    }

    /**
     * Single-query helper to enrich a user list with MFA status without the
     * N+1 cost of calling getUserMfa() per row.
     */
    public getUsersWithMfaEnabled(): Set<number> {
        const rows = this.db.prepare('SELECT user_id FROM user_mfa WHERE enabled = 1').all() as { user_id: number }[];
        return new Set(rows.map((r) => r.user_id));
    }

    public recordMfaFailure(userId: number): number {
        const row = this.db.prepare(
            `UPDATE user_mfa
                SET failed_attempts = failed_attempts + 1,
                    updated_at = ?
              WHERE user_id = ?
          RETURNING failed_attempts`
        ).get(Date.now(), userId) as { failed_attempts: number } | undefined;
        return row?.failed_attempts ?? 0;
    }

    public clearMfaFailures(userId: number): void {
        this.db.prepare(
            `UPDATE user_mfa
                SET failed_attempts = 0,
                    locked_until = NULL,
                    updated_at = ?
              WHERE user_id = ?`
        ).run(Date.now(), userId);
    }

    public lockMfa(userId: number, untilMs: number): void {
        this.db.prepare(
            `UPDATE user_mfa SET locked_until = ?, updated_at = ? WHERE user_id = ?`
        ).run(untilMs, Date.now(), userId);
    }

    public isMfaCodeUsed(userId: number, code: string, window: number): boolean {
        const row = this.db.prepare(
            'SELECT 1 FROM mfa_used_tokens WHERE user_id = ? AND code = ? AND window = ?'
        ).get(userId, code, window);
        return !!row;
    }

    public markMfaCodeUsed(userId: number, code: string, window: number): void {
        this.db.prepare(
            'INSERT OR IGNORE INTO mfa_used_tokens (user_id, code, window, used_at) VALUES (?, ?, ?, ?)'
        ).run(userId, code, window, Date.now());
    }

    public purgeOldMfaCodes(olderThanMs: number): number {
        const result = this.db.prepare('DELETE FROM mfa_used_tokens WHERE used_at < ?').run(olderThanMs);
        return result.changes;
    }

    /**
     * Atomically consume a single backup-code hash. Re-reads the stored set,
     * removes `matchedHash` if still present, and persists the shrunk set in
     * one synchronous transaction. Returns true when the hash was present (and
     * is now consumed), false when it was already gone (e.g. a concurrent
     * /login/mfa request carrying the same code consumed it first). This is the
     * single-use enforcement point: callers verify the code, then gate success
     * on this returning true, so two concurrent verifications of the same code
     * cannot both succeed.
     */
    public consumeBackupCodeHash(userId: number, matchedHash: string): boolean {
        const consume = this.db.transaction((): boolean => {
            const row = this.db
                .prepare('SELECT backup_codes_json FROM user_mfa WHERE user_id = ?')
                .get(userId) as { backup_codes_json: string | null } | undefined;
            const hashes: string[] = row?.backup_codes_json ? JSON.parse(row.backup_codes_json) : [];
            const idx = hashes.indexOf(matchedHash);
            if (idx === -1) return false;
            hashes.splice(idx, 1);
            this.db
                .prepare('UPDATE user_mfa SET backup_codes_json = ?, updated_at = ? WHERE user_id = ?')
                .run(JSON.stringify(hashes), Date.now(), userId);
            return true;
        });
        return consume();
    }

    // --- Role Assignments ---

    public getRoleAssignments(
        userId: number,
        resourceType: ResourceType,
        resourceId: string,
        nodeId?: number | null,
    ): RoleAssignment[] {
        if (resourceType === 'stack') {
            if (nodeId === undefined || nodeId === null) return [];
            return this.db.prepare(
                'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id = ?'
            ).all(userId, resourceType, resourceId, nodeId) as RoleAssignment[];
        }
        return this.db.prepare(
            'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id IS NULL'
        ).all(userId, resourceType, resourceId) as RoleAssignment[];
    }

    public getAllRoleAssignments(userId: number): RoleAssignment[] {
        return this.db.prepare(
            'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id, node_id'
        ).all(userId) as RoleAssignment[];
    }

    public addRoleAssignment(assignment: {
        user_id: number;
        role: UserRole;
        resource_type: ResourceType;
        resource_id: string;
        node_id?: number | null;
    }): number {
        const now = Date.now();
        const nodeId = assignment.resource_type === 'stack' ? assignment.node_id ?? null : null;
        if (assignment.resource_type === 'stack' && (nodeId === null || nodeId === undefined)) {
            throw new Error('node_id is required for stack role assignments');
        }
        const result = this.db.prepare(
            'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, node_id, created_at) VALUES (?, ?, ?, ?, ?, ?)'
        ).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, nodeId, now);
        return result.lastInsertRowid as number;
    }

    public getRoleAssignmentById(id: number): RoleAssignment | undefined {
        return this.db.prepare('SELECT * FROM role_assignments WHERE id = ?').get(id) as RoleAssignment | undefined;
    }

    public deleteRoleAssignment(id: number): void {
        this.db.prepare('DELETE FROM role_assignments WHERE id = ?').run(id);
    }

    public deleteRoleAssignmentsByUser(userId: number): void {
        this.db.prepare('DELETE FROM role_assignments WHERE user_id = ?').run(userId);
    }

    public deleteRoleAssignmentsByResource(resourceType: ResourceType, resourceId: string): void {
        this.db.prepare('DELETE FROM role_assignments WHERE resource_type = ? AND resource_id = ?').run(resourceType, resourceId);
    }

    /** Clear stack-scoped grants for one (nodeId, stackName) tuple. */
    public deleteRoleAssignmentsByStack(nodeId: number, stackName: string): void {
        this.db.prepare(
            "DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ? AND resource_id = ?"
        ).run(nodeId, stackName);
    }

    /** Clear all stack-scoped grants for a node (explicit cleanup; FK CASCADE is not enforced). */
    public deleteRoleAssignmentsByStackNode(nodeId: number): void {
        this.db.prepare(
            "DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ?"
        ).run(nodeId);
    }

    // --- SSO Config ---

    public getSSOConfigs(): SSOConfig[] {
        return this.db.prepare('SELECT * FROM sso_config ORDER BY provider ASC').all() as SSOConfig[];
    }

    public getSSOConfig(provider: string): SSOConfig | undefined {
        return this.db.prepare('SELECT * FROM sso_config WHERE provider = ?').get(provider) as SSOConfig | undefined;
    }

    public getEnabledSSOConfigs(): SSOConfig[] {
        return this.db.prepare('SELECT * FROM sso_config WHERE enabled = 1 ORDER BY provider ASC').all() as SSOConfig[];
    }

    public upsertSSOConfig(provider: string, enabled: boolean, configJson: string): void {
        const now = Date.now();
        const existing = this.getSSOConfig(provider);
        if (existing) {
            this.db.prepare('UPDATE sso_config SET enabled = ?, config_json = ?, updated_at = ? WHERE provider = ?')
                .run(enabled ? 1 : 0, configJson, now, provider);
        } else {
            this.db.prepare('INSERT INTO sso_config (provider, enabled, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
                .run(provider, enabled ? 1 : 0, configJson, now, now);
        }
    }

    public deleteSSOConfig(provider: string): void {
        this.db.prepare('DELETE FROM sso_config WHERE provider = ?').run(provider);
    }

    // --- Fleet Snapshots ---

    public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string, skippedStacks = '[]', documentation = ''): number {
        // Dossier metadata can carry operational notes (static IPs, firewall
        // rules); encrypt it at rest with the same instance key as the file
        // bodies. An empty string means the snapshot captured no documentation.
        const storedDocs = documentation === '' ? '' : CryptoService.getInstance().encrypt(documentation);
        const result = this.db.prepare(
            'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, documentation, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
        ).run(description, createdBy, nodeCount, stackCount, skippedNodes, skippedStacks, storedDocs, Date.now());
        return result.lastInsertRowid as number;
    }

    public insertSnapshotFiles(snapshotId: number, files: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>): void {
        // Snapshot file bodies are compose.yaml and .env captures, so they carry
        // the same secrets as the live stack. Encrypt content at rest with the
        // instance key. Getters classify and decrypt per row (see
        // snapshotFileDecrypt.ts); unavailable rows omit content so callers
        // cannot treat damage as usable plaintext.
        const crypto = CryptoService.getInstance();
        const insert = this.db.prepare(
            'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)'
        );
        const insertMany = this.db.transaction((rows: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>) => {
            for (const row of rows) {
                insert.run(snapshotId, row.nodeId, row.nodeName, row.stackName, row.filename, crypto.encrypt(row.content));
            }
        });
        insertMany(files);
    }

    // The encrypted `documentation` blob is deliberately excluded from these
    // projections (it can be large and is decrypted only on demand). Callers
    // get a cheap `has_documentation` flag; read the blob with
    // getSnapshotDocumentation().
    private static readonly SNAPSHOT_COLUMNS =
        "id, description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, created_at, (documentation != '') AS has_documentation";

    public getSnapshots(limit = 50, offset = 0): FleetSnapshot[] {
        return this.db.prepare(
            `SELECT ${DatabaseService.SNAPSHOT_COLUMNS} FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?`
        ).all(limit, offset) as FleetSnapshot[];
    }

    public getSnapshot(id: number): FleetSnapshot | undefined {
        return this.db.prepare(`SELECT ${DatabaseService.SNAPSHOT_COLUMNS} FROM fleet_snapshots WHERE id = ?`).get(id) as FleetSnapshot | undefined;
    }

    /** Decrypted Stack Dossier metadata JSON captured with the snapshot, or '' when none. */
    public getSnapshotDocumentation(id: number): string {
        const row = this.db.prepare('SELECT documentation FROM fleet_snapshots WHERE id = ?').get(id) as { documentation: string } | undefined;
        if (!row || row.documentation === '') return '';
        try {
            // decrypt() returns non-ciphertext input unchanged.
            return CryptoService.getInstance().decrypt(row.documentation);
        } catch (e) {
            // A corrupt blob or a key rotation must not break the primary backup
            // flows: documentation is an optional side payload, so degrade to
            // "no documentation" rather than failing upload/detail/restore.
            console.error(`[DatabaseService] Failed to decrypt documentation for snapshot ${id}:`, (e as Error).message);
            return '';
        }
    }

    // Per-row classification isolates corrupt encrypted rows so intact stacks
    // remain readable. See helpers/snapshotFileDecrypt.ts.
    public getSnapshotFiles(snapshotId: number): SnapshotFileReadResult[] {
        const rows = this.db.prepare(
            'SELECT node_id, node_name, stack_name, filename, content FROM fleet_snapshot_files WHERE snapshot_id = ? ORDER BY node_name, stack_name'
        ).all(snapshotId) as SnapshotFileRow[];
        return this.mapSnapshotFileRows(rows, snapshotId);
    }

    public getSnapshotStackFiles(snapshotId: number, nodeId: number, stackName: string): SnapshotFileReadResult[] {
        const rows = this.db.prepare(
            'SELECT node_id, node_name, stack_name, filename, content FROM fleet_snapshot_files WHERE snapshot_id = ? AND node_id = ? AND stack_name = ?'
        ).all(snapshotId, nodeId, stackName) as SnapshotFileRow[];
        return this.mapSnapshotFileRows(rows, snapshotId);
    }

    private mapSnapshotFileRows(rows: SnapshotFileRow[], snapshotId: number): SnapshotFileReadResult[] {
        return rows.map(row => readSnapshotFileRow(row, snapshotId));
    }

    /** Created-at of the most recent fleet snapshot covering a stack, or null. */
    public getLatestSnapshotTimestampFor(nodeId: number, stackName: string): number | null {
        const row = this.db.prepare(
            `SELECT MAX(s.created_at) AS latest
             FROM fleet_snapshots s
             JOIN fleet_snapshot_files f ON f.snapshot_id = s.id
             WHERE f.node_id = ? AND f.stack_name = ?`
        ).get(nodeId, stackName) as { latest: number | null } | undefined;
        return row?.latest ?? null;
    }

    public deleteSnapshot(id: number): void {
        this.db.prepare('DELETE FROM fleet_snapshots WHERE id = ?').run(id);
    }

    public getSnapshotCount(): number {
        return (this.db.prepare('SELECT COUNT(*) as count FROM fleet_snapshots').get() as { count: number })?.count || 0;
    }

    // --- Audit Log ---

    public insertAuditLog(entry: Omit<AuditLogEntry, 'id'>): void {
        this.auditLogBuffer.push(entry);
        if (this.auditLogBuffer.length >= AUDIT_LOG_FLUSH_THRESHOLD) {
            this.flushAuditLogBuffer();
            return;
        }
        if (!this.auditLogFlushTimer) {
            // unref(): the buffer flush should not by itself keep the process
            // alive. The HTTP server keeps the event loop running during
            // normal operation; on shutdown, the explicit flush in
            // bootstrap/shutdown.ts drains the buffer before db.close().
            this.auditLogFlushTimer = setTimeout(
                () => this.flushAuditLogBuffer(),
                AUDIT_LOG_FLUSH_INTERVAL_MS,
            );
            this.auditLogFlushTimer.unref();
        }
    }

    /**
     * Drain the audit-log buffer to disk in a single transaction. Safe to
     * call from any path: read methods flush before querying so callers
     * observe buffered writes, and the shutdown handler flushes before the
     * DB connection closes.
     */
    public flushAuditLogBuffer(): void {
        if (this.auditLogFlushTimer) {
            clearTimeout(this.auditLogFlushTimer);
            this.auditLogFlushTimer = null;
        }
        if (this.auditLogBuffer.length === 0) return;
        // Swap the buffer reference before running the transaction. Any
        // re-entrant insertAuditLog call (e.g. from a future hook that audits
        // its own writes) lands on the new empty buffer and survives to the
        // next flush, instead of being captured into the in-flight batch and
        // potentially dropped on transaction failure.
        const batch = this.auditLogBuffer;
        this.auditLogBuffer = [];
        if (!this.auditLogInsertStmt) {
            this.auditLogInsertStmt = this.db.prepare(
                'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary, acting_as) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
            );
        }
        const stmt = this.auditLogInsertStmt;
        const insertMany = this.db.transaction((entries: typeof batch) => {
            for (const entry of entries) {
                stmt.run(
                    entry.timestamp,
                    entry.username,
                    entry.method,
                    entry.path,
                    entry.status_code,
                    entry.node_id,
                    entry.ip_address,
                    entry.summary,
                    entry.acting_as ?? null,
                );
            }
        });
        try {
            insertMany(batch);
        } catch (err) {
            console.error('[Audit] Failed to flush audit log buffer:', err);
        }
    }

    public getAuditLogs(filters: {
        page?: number;
        limit?: number;
        username?: string;
        method?: string;
        from?: number;
        to?: number;
        search?: string;
    } = {}): { entries: AuditLogEntry[]; total: number } {
        this.flushAuditLogBuffer();
        const page = filters.page ?? 1;
        const limit = filters.limit ?? 50;
        const offset = (page - 1) * limit;

        const conditions: string[] = [];
        const params: (string | number)[] = [];

        if (filters.username) {
            conditions.push('username = ?');
            params.push(filters.username);
        }
        if (filters.method) {
            conditions.push('method = ?');
            params.push(filters.method);
        }
        if (filters.from) {
            conditions.push('timestamp >= ?');
            params.push(filters.from);
        }
        if (filters.to) {
            conditions.push('timestamp <= ?');
            params.push(filters.to);
        }
        if (filters.search) {
            conditions.push('(summary LIKE ? OR path LIKE ? OR username LIKE ?)');
            const term = `%${filters.search}%`;
            params.push(term, term, term);
        }

        const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

        const total = (this.db.prepare(`SELECT COUNT(*) as count FROM audit_log ${where}`).get(...params) as { count: number })?.count || 0;
        const entries = this.db.prepare(
            `SELECT * FROM audit_log ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`
        ).all(...params, limit, offset) as AuditLogEntry[];

        return { entries, total };
    }

    public cleanupOldAuditLogs(daysToKeep = 90): void {
        this.flushAuditLogBuffer();
        const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
        this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff);
    }

    public insertRegistryDeliveryEvent(params: {
        deliverySourceId: string;
        eventType: string;
        stack?: string | null;
        op?: string | null;
        attestationJti?: string | null;
        prepIdSha256?: string | null;
        tempDirId?: string | null;
        sourceHash?: string | null;
        prunedThroughSeq?: number | null;
    }): number {
        const eventId = crypto.randomUUID();
        const createdAt = Date.now();
        const result = this.db.prepare(`
            INSERT INTO registry_delivery_events (
                event_id, delivery_source_id, stack, op, attestation_jti,
                prep_id_sha256, temp_dir_id, event_type, source_hash, pruned_through_seq, created_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        `).run(
            eventId,
            params.deliverySourceId,
            params.stack ?? null,
            params.op ?? null,
            params.attestationJti ?? null,
            params.prepIdSha256 ?? null,
            params.tempDirId ?? null,
            params.eventType,
            params.sourceHash ?? null,
            params.prunedThroughSeq ?? null,
            createdAt,
        );
        return Number(result.lastInsertRowid);
    }

    public listRegistryDeliveryEvents(
        deliverySourceId: string,
        afterSeq: number,
        limit: number,
    ): import('../types/registryDeliveryEvidence').RegistryDeliveryEventRow[] {
        return this.db.prepare(`
            SELECT seq, event_id, delivery_source_id, stack, op, attestation_jti,
                   prep_id_sha256, temp_dir_id, event_type, source_hash, pruned_through_seq, created_at
            FROM registry_delivery_events
            WHERE delivery_source_id = ? AND seq > ?
            ORDER BY seq ASC
            LIMIT ?
        `).all(deliverySourceId, afterSeq, limit) as import('../types/registryDeliveryEvidence').RegistryDeliveryEventRow[];
    }

    public getRegistryDeliveryImportCursor(deliverySourceId: string): number {
        const row = this.db.prepare(
            'SELECT last_seq FROM registry_delivery_import_cursor WHERE delivery_source_id = ?',
        ).get(deliverySourceId) as { last_seq: number } | undefined;
        return row?.last_seq ?? 0;
    }

    public importRegistryDeliveryEventPage(
        hubNodeIdSnapshot: number,
        deliverySourceId: string,
        events: import('../types/registryDeliveryEvidence').RegistryDeliveryEventRow[],
    ): { imported: number; lastSeq: number } {
        if (events.length === 0) {
            return {
                imported: 0,
                lastSeq: this.getRegistryDeliveryImportCursor(deliverySourceId),
            };
        }

        const importPage = this.db.transaction((rows: import('../types/registryDeliveryEvidence').RegistryDeliveryEventRow[]) => {
            const insert = this.db.prepare(`
                INSERT OR IGNORE INTO registry_delivery_imported_events (
                    event_id, delivery_source_id, seq, hub_node_id_snapshot,
                    stack, op, attestation_jti, prep_id_sha256, temp_dir_id,
                    event_type, source_hash, pruned_through_seq, created_at, imported_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            `);
            let imported = 0;
            let maxSeq = this.getRegistryDeliveryImportCursor(deliverySourceId);
            const now = Date.now();
            for (const row of rows) {
                const result = insert.run(
                    row.event_id,
                    deliverySourceId,
                    row.seq,
                    hubNodeIdSnapshot,
                    row.stack,
                    row.op,
                    row.attestation_jti,
                    row.prep_id_sha256,
                    row.temp_dir_id,
                    row.event_type,
                    row.source_hash,
                    row.pruned_through_seq,
                    row.created_at,
                    now,
                );
                if (result.changes > 0) imported += 1;
                if (row.seq > maxSeq) maxSeq = row.seq;
            }
            this.db.prepare(`
                INSERT INTO registry_delivery_import_cursor (delivery_source_id, last_seq, updated_at)
                VALUES (?, ?, ?)
                ON CONFLICT(delivery_source_id) DO UPDATE SET
                    last_seq = CASE
                        WHEN excluded.last_seq > registry_delivery_import_cursor.last_seq
                        THEN excluded.last_seq
                        ELSE registry_delivery_import_cursor.last_seq
                    END,
                    updated_at = excluded.updated_at
            `).run(deliverySourceId, maxSeq, now);
            return { imported, lastSeq: maxSeq };
        });

        return importPage(events);
    }

    public cleanupOldDeliveryEvents(daysToKeep = 90): number {
        const deliverySourceId = this.getGlobalSettings().delivery_source_id;
        if (!deliverySourceId) return 0;

        const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
        const maxPruned = this.db.prepare(`
            SELECT MAX(seq) as maxSeq FROM registry_delivery_events
            WHERE delivery_source_id = ? AND created_at < ?
        `).get(deliverySourceId, cutoff) as { maxSeq: number | null } | undefined;
        const prunedThrough = maxPruned?.maxSeq ?? null;
        if (prunedThrough === null) return 0;

        const result = this.db.prepare(`
            DELETE FROM registry_delivery_events
            WHERE delivery_source_id = ? AND created_at < ?
        `).run(deliverySourceId, cutoff);

        if (result.changes > 0) {
            this.insertRegistryDeliveryEvent({
                deliverySourceId,
                eventType: 'retention_gap',
                prunedThroughSeq: prunedThrough,
            });
        }
        return result.changes;
    }

    public getAuditLogsInRange(from: number, to: number, limit?: number): AuditLogEntry[] {
        this.flushAuditLogBuffer();
        if (limit !== undefined) {
            // Cap to the most-recent `limit` rows in the window, then return
            // them in ascending order to preserve this method's contract.
            return this.db.prepare(
                `SELECT * FROM (
                   SELECT * FROM audit_log WHERE timestamp >= ? AND timestamp < ?
                   ORDER BY timestamp DESC LIMIT ?
                 ) ORDER BY timestamp ASC`
            ).all(from, to, limit) as AuditLogEntry[];
        }
        return this.db.prepare(
            'SELECT * FROM audit_log WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC'
        ).all(from, to) as AuditLogEntry[];
    }

    /**
     * Exact aggregate inputs for the audit signal-rail stats, computed with SQL
     * COUNT / GROUP BY rather than materializing rows. The counts and hourly
     * series stay exact regardless of window size (no row cap), while the
     * new-ip detection works over the small DISTINCT (user, ip) pair sets.
     */
    public getAuditStatsInputs(now: number): AuditStatsInput {
        this.flushAuditLogBuffer();
        const cutoff24h = now - 24 * 60 * 60 * 1000;
        const cutoff7d = now - 7 * 24 * 60 * 60 * 1000;
        const cutoff30d = now - 30 * 24 * 60 * 60 * 1000;
        // Every current-window query is upper-bounded by `now` so a future-dated
        // row (clock skew, a test fixture) never inflates the live counts.
        const countOf = (sql: string, ...params: number[]): number =>
            (this.db.prepare(sql).get(...params) as { c: number }).c;

        const events24 = countOf('SELECT COUNT(*) AS c FROM audit_log WHERE timestamp >= ? AND timestamp < ?', cutoff24h, now);
        const events7d = countOf('SELECT COUNT(*) AS c FROM audit_log WHERE timestamp >= ? AND timestamp < ?', cutoff7d, now);
        const actors24 = countOf("SELECT COUNT(DISTINCT username) AS c FROM audit_log WHERE timestamp >= ? AND timestamp < ? AND username != ''", cutoff24h, now);
        const failures24 = countOf('SELECT COUNT(*) AS c FROM audit_log WHERE timestamp >= ? AND timestamp < ? AND status_code >= 400', cutoff24h, now);

        const activityByHour = Array.from({ length: 24 }, () => 0);
        const failuresByHour = Array.from({ length: 24 }, () => 0);
        const hourRows = this.db.prepare(
            `SELECT CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER) AS hour,
                    COUNT(*) AS total,
                    SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS failures
             FROM audit_log WHERE timestamp >= ? AND timestamp < ? GROUP BY hour`,
        ).all(cutoff24h, now) as { hour: number; total: number; failures: number }[];
        for (const r of hourRows) {
            if (r.hour >= 0 && r.hour < 24) {
                activityByHour[r.hour] = r.total;
                failuresByHour[r.hour] = r.failures;
            }
        }

        // ORDER BY makes both the new-ip scan and the sample actor deterministic.
        const recentPairs = this.db.prepare(
            "SELECT DISTINCT username, ip_address FROM audit_log WHERE timestamp >= ? AND timestamp < ? AND username != '' AND ip_address != '' ORDER BY username, ip_address",
        ).all(cutoff24h, now) as { username: string; ip_address: string }[];
        const priorPairs = this.db.prepare(
            "SELECT DISTINCT username, ip_address FROM audit_log WHERE timestamp >= ? AND timestamp < ? AND username != '' AND ip_address != ''",
        ).all(cutoff30d, cutoff24h) as { username: string; ip_address: string }[];
        const priorByActor = new Map<string, Set<string>>();
        for (const p of priorPairs) {
            let set = priorByActor.get(p.username);
            if (!set) { set = new Set(); priorByActor.set(p.username, set); }
            set.add(p.ip_address);
        }
        let newIpCount = 0;
        let sampleNewIpActor: string | null = null;
        for (const p of recentPairs) {
            const prior = priorByActor.get(p.username);
            if (prior && prior.size > 0 && !prior.has(p.ip_address)) {
                newIpCount++;
                if (!sampleNewIpActor) sampleNewIpActor = p.username;
            }
        }

        return { events24, events7d, actors24, failures24, activityByHour, failuresByHour, newIpCount, sampleNewIpActor };
    }

    // --- API Tokens ---

    public addApiToken(token: Omit<ApiToken, 'id' | 'last_used_at' | 'revoked_at'>): number {
        const result = this.db.prepare(
            'INSERT INTO api_tokens (token_hash, name, scope, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)'
        ).run(token.token_hash, token.name, token.scope, token.user_id, token.created_at, token.expires_at);
        return result.lastInsertRowid as number;
    }

    public getApiTokensByUser(userId: number): ApiToken[] {
        return this.db.prepare(
            'SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC'
        ).all(userId) as ApiToken[];
    }

    public getApiTokenByHash(tokenHash: string): ApiToken | undefined {
        return this.db.prepare(
            'SELECT * FROM api_tokens WHERE token_hash = ?'
        ).get(tokenHash) as ApiToken | undefined;
    }

    public getApiTokenById(id: number): ApiToken | undefined {
        return this.db.prepare(
            'SELECT * FROM api_tokens WHERE id = ?'
        ).get(id) as ApiToken | undefined;
    }

    public revokeApiToken(id: number): void {
        this.db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE id = ?').run(Date.now(), id);
    }

    public updateApiTokenLastUsed(id: number): void {
        this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id);
    }

    public getActiveApiTokenCountByUser(userId: number): number {
        const row = this.db.prepare(
            'SELECT COUNT(*) AS cnt FROM api_tokens WHERE user_id = ? AND revoked_at IS NULL'
        ).get(userId) as { cnt: number };
        return row.cnt;
    }

    public getActiveApiTokenByNameAndUser(name: string, userId: number): ApiToken | undefined {
        return this.db.prepare(
            'SELECT * FROM api_tokens WHERE name = ? AND user_id = ? AND revoked_at IS NULL LIMIT 1'
        ).get(name, userId) as ApiToken | undefined;
    }

    // --- Registries ---

    public getRegistries(): Registry[] {
        return this.db.prepare('SELECT * FROM registries ORDER BY name ASC').all() as Registry[];
    }

    public getRegistry(id: number): Registry | undefined {
        return this.db.prepare('SELECT * FROM registries WHERE id = ?').get(id) as Registry | undefined;
    }

    public addRegistry(reg: Omit<Registry, 'id'>): number {
        const result = this.db.prepare(
            'INSERT INTO registries (name, url, type, username, secret, aws_region, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
        ).run(reg.name, reg.url, reg.type, reg.username, reg.secret, reg.aws_region, reg.created_at, reg.updated_at);
        return result.lastInsertRowid as number;
    }

    public updateRegistry(id: number, updates: Partial<Omit<Registry, 'id' | 'created_at'>>): void {
        const fields: string[] = [];
        const values: unknown[] = [];

        if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
        if (updates.url !== undefined) { fields.push('url = ?'); values.push(updates.url); }
        if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
        if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
        if (updates.secret !== undefined) { fields.push('secret = ?'); values.push(updates.secret); }
        if (updates.aws_region !== undefined) { fields.push('aws_region = ?'); values.push(updates.aws_region); }
        if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }

        if (fields.length === 0) return;

        values.push(id);
        this.db.prepare(`UPDATE registries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
    }

    public deleteRegistry(id: number): void {
        this.db.prepare('DELETE FROM registries WHERE id = ?').run(id);
    }

    // --- Stack Git Sources ---

    /**
     * Normalize the stored compose_paths column to a non-empty ordered array.
     * Legacy rows (column null), empty arrays, or malformed JSON all fall back
     * to the single compose_path so every consumer can rely on a usable list.
     */
    private normalizeComposePaths(raw: unknown, composePath: string): string[] {
        if (typeof raw === 'string' && raw.trim()) {
            try {
                const parsed = JSON.parse(raw);
                if (Array.isArray(parsed)) {
                    const paths = parsed.filter((p): p is string => typeof p === 'string' && p.trim().length > 0);
                    if (paths.length > 0) return paths;
                }
            } catch {
                // fall through to the single-path fallback
            }
        }
        return [composePath];
    }

    private parseAppliedDeploySpec(raw: unknown): GitSourceAppliedSpec | null {
        if (typeof raw !== 'string' || !raw.trim()) return null;
        try {
            const parsed = JSON.parse(raw) as Partial<GitSourceAppliedSpec>;
            if (!parsed || !Array.isArray(parsed.files)) return null;
            const files = parsed.files.filter((f): f is string => typeof f === 'string' && f.trim().length > 0);
            if (files.length === 0) return null;
            return { files, contextDir: typeof parsed.contextDir === 'string' ? parsed.contextDir : null };
        } catch {
            return null;
        }
    }

    private parseGitSource(row: Record<string, unknown> | undefined): StackGitSource | undefined {
        if (!row) return undefined;
        const composePath = row.compose_path as string;
        return {
            id: row.id as number,
            stack_name: row.stack_name as string,
            repo_url: row.repo_url as string,
            branch: row.branch as string,
            compose_path: composePath,
            compose_paths: this.normalizeComposePaths(row.compose_paths, composePath),
            context_dir: (row.context_dir as string | null) ?? null,
            applied_deploy_spec: this.parseAppliedDeploySpec(row.applied_deploy_spec),
            manifest_version: row.manifest_version !== undefined && row.manifest_version !== null ? Number(row.manifest_version) : null,
            manifest_state: (row.manifest_state as GitSourceManifestState | null) ?? null,
            manifest_generation: (row.manifest_generation as string | null) ?? null,
            sync_env: Number(row.sync_env) === 1,
            env_path: (row.env_path as string | null) ?? null,
            auth_type: row.auth_type as GitSourceAuthType,
            encrypted_token: (row.encrypted_token as string | null) ?? null,
            encrypted_deploy_key: (row.encrypted_deploy_key as string | null) ?? null,
            ssh_known_hosts_entry: (row.ssh_known_hosts_entry as string | null) ?? null,
            ssh_host_key_fingerprint: (row.ssh_host_key_fingerprint as string | null) ?? null,
            encrypted_ca_bundle: (row.encrypted_ca_bundle as string | null) ?? null,
            auto_apply_on_webhook: Number(row.auto_apply_on_webhook) === 1,
            auto_deploy_on_apply: Number(row.auto_deploy_on_apply) === 1,
            last_applied_commit_sha: (row.last_applied_commit_sha as string | null) ?? null,
            last_applied_content_hash: (row.last_applied_content_hash as string | null) ?? null,
            pending_commit_sha: (row.pending_commit_sha as string | null) ?? null,
            pending_compose_content: (row.pending_compose_content as string | null) ?? null,
            pending_env_content: (row.pending_env_content as string | null) ?? null,
            pending_fetched_at: (row.pending_fetched_at as number | null) ?? null,
            last_debounce_at: (row.last_debounce_at as number | null) ?? null,
            pending_plan_fingerprint: (row.pending_plan_fingerprint as string | null) ?? null,
            pending_plan_blocked: row.pending_plan_blocked === undefined || row.pending_plan_blocked === null
                ? null
                : Number(row.pending_plan_blocked) === 1,
            pending_plan_summary: (row.pending_plan_summary as string | null) ?? null,
            last_plan_fingerprint: (row.last_plan_fingerprint as string | null) ?? null,
            last_plan_outcome: (row.last_plan_outcome as string | null) ?? null,
            created_at: row.created_at as number,
            updated_at: row.updated_at as number,
        };
    }

    public getGitSource(stackName: string): StackGitSource | undefined {
        const row = this.db.prepare('SELECT * FROM stack_git_sources WHERE stack_name = ?').get(stackName) as Record<string, unknown> | undefined;
        return this.parseGitSource(row);
    }

    public getGitSources(): StackGitSource[] {
        const rows = this.db.prepare('SELECT * FROM stack_git_sources ORDER BY stack_name ASC').all() as Record<string, unknown>[];
        return rows.map(r => this.parseGitSource(r)!);
    }

    public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at' | 'applied_deploy_spec' | 'manifest_version' | 'manifest_state' | 'manifest_generation' | 'pending_plan_fingerprint' | 'pending_plan_blocked' | 'pending_plan_summary' | 'last_plan_fingerprint' | 'last_plan_outcome'>): number {
        const now = Date.now();
        const existing = this.getGitSource(source.stack_name);
        const composePathsJson = JSON.stringify(source.compose_paths ?? [source.compose_path]);
        if (existing) {
            this.db.prepare(
                `UPDATE stack_git_sources SET
                    repo_url = ?, branch = ?, compose_path = ?, compose_paths = ?, context_dir = ?,
                    sync_env = ?, env_path = ?,
                    auth_type = ?, encrypted_token = ?, encrypted_deploy_key = ?,
                    ssh_known_hosts_entry = ?, ssh_host_key_fingerprint = ?,
                    encrypted_ca_bundle = ?,
                    auto_apply_on_webhook = ?, auto_deploy_on_apply = ?,
                    updated_at = ?
                 WHERE stack_name = ?`
            ).run(
                source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
                source.sync_env ? 1 : 0, source.env_path,
                source.auth_type, source.encrypted_token, source.encrypted_deploy_key,
                source.ssh_known_hosts_entry, source.ssh_host_key_fingerprint,
                source.encrypted_ca_bundle,
                source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
                now, source.stack_name
            );
            return existing.id!;
        }
        const result = this.db.prepare(
            `INSERT INTO stack_git_sources
                (stack_name, repo_url, branch, compose_path, compose_paths, context_dir, sync_env, env_path,
                 auth_type, encrypted_token, encrypted_deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint,
                 encrypted_ca_bundle,
                 auto_apply_on_webhook, auto_deploy_on_apply,
                 created_at, updated_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        ).run(
            source.stack_name, source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
            source.sync_env ? 1 : 0, source.env_path,
            source.auth_type, source.encrypted_token, source.encrypted_deploy_key,
            source.ssh_known_hosts_entry, source.ssh_host_key_fingerprint,
            source.encrypted_ca_bundle,
            source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
            now, now
        );
        return result.lastInsertRowid as number;
    }

    /**
     * Persist (or clear) the deploy-time materialized compose file set. Called by
     * the Git apply/create paths after files land on disk; the deploy/lifecycle
     * path, the mesh service, and the image-update path read it back via
     * getGitSource(). Passing null resets the stack to single-file auto-discovery.
     */
    public setGitSourceAppliedSpec(stackName: string, spec: GitSourceAppliedSpec | null): void {
        this.db.prepare(
            `UPDATE stack_git_sources SET applied_deploy_spec = ?, updated_at = ? WHERE stack_name = ?`
        ).run(spec ? JSON.stringify(spec) : null, Date.now(), stackName);
    }

    public deleteGitSource(stackName: string): void {
        this.db.prepare('DELETE FROM stack_git_sources WHERE stack_name = ?').run(stackName);
    }

    /**
     * Persist the managed-project manifest cache columns. The manifest FILE in
     * the managed area is the source of truth; these columns are cheap
     * projections for GET/dashboard reads and carry the two states the file
     * cannot express ('migration_required', 'absent').
     */
    public setGitSourceManifestState(stackName: string, version: number | null, state: GitSourceManifestState | null, generation: string | null): void {
        this.db.prepare(
            `UPDATE stack_git_sources SET manifest_version = ?, manifest_state = ?, manifest_generation = ?, updated_at = ? WHERE stack_name = ?`
        ).run(version, state, generation, Date.now(), stackName);
    }

    public setGitSourcePending(
        stackName: string,
        commitSha: string,
        composeContent: string,
        envContent: string | null,
        plan?: { fingerprint: string; blocked: boolean; summary: string },
    ): void {
        this.db.prepare(
            `UPDATE stack_git_sources SET
                pending_commit_sha = ?,
                pending_compose_content = ?,
                pending_env_content = ?,
                pending_fetched_at = ?,
                pending_plan_fingerprint = ?,
                pending_plan_blocked = ?,
                pending_plan_summary = ?,
                updated_at = ?
             WHERE stack_name = ?`
        ).run(
            commitSha,
            composeContent,
            envContent,
            Date.now(),
            plan?.fingerprint ?? null,
            plan ? (plan.blocked ? 1 : 0) : null,
            plan?.summary ?? null,
            Date.now(),
            stackName,
        );
    }

    public updateGitSourcePendingPlan(
        stackName: string,
        composeContent: string,
        plan: { fingerprint: string; blocked: boolean; summary: string },
    ): void {
        this.db.prepare(
            `UPDATE stack_git_sources SET
                pending_compose_content = ?,
                pending_plan_fingerprint = ?,
                pending_plan_blocked = ?,
                pending_plan_summary = ?,
                updated_at = ?
             WHERE stack_name = ?`
        ).run(composeContent, plan.fingerprint, plan.blocked ? 1 : 0, plan.summary, Date.now(), stackName);
    }

    public setGitSourceLastPlan(stackName: string, fingerprint: string | null, outcome: string | null): void {
        this.db.prepare(
            `UPDATE stack_git_sources SET last_plan_fingerprint = ?, last_plan_outcome = ?, updated_at = ? WHERE stack_name = ?`
        ).run(fingerprint, outcome, Date.now(), stackName);
    }

    public clearGitSourcePending(stackName: string): void {
        this.db.prepare(
            `UPDATE stack_git_sources SET
                pending_commit_sha = NULL,
                pending_compose_content = NULL,
                pending_env_content = NULL,
                pending_fetched_at = NULL,
                pending_plan_fingerprint = NULL,
                pending_plan_blocked = NULL,
                pending_plan_summary = NULL,
                updated_at = ?
             WHERE stack_name = ?`
        ).run(Date.now(), stackName);
    }

    public markGitSourceApplied(stackName: string, commitSha: string, contentHash: string): void {
        this.db.prepare(
            `UPDATE stack_git_sources SET
                last_applied_commit_sha = ?,
                last_applied_content_hash = ?,
                pending_commit_sha = NULL,
                pending_compose_content = NULL,
                pending_env_content = NULL,
                pending_fetched_at = NULL,
                pending_plan_fingerprint = NULL,
                pending_plan_blocked = NULL,
                pending_plan_summary = NULL,
                updated_at = ?
             WHERE stack_name = ?`
        ).run(commitSha, contentHash, Date.now(), stackName);
    }

    /**
     * Clear the last-applied revision identity without removing the Git source
     * row. Used when compensating to a capture that had a null commit SHA
     * (first-apply preimage).
     */
    public clearGitSourceAppliedRevision(stackName: string): void {
        this.db.prepare(
            `UPDATE stack_git_sources SET
                last_applied_commit_sha = NULL,
                last_applied_content_hash = NULL,
                pending_commit_sha = NULL,
                pending_compose_content = NULL,
                pending_env_content = NULL,
                pending_fetched_at = NULL,
                pending_plan_fingerprint = NULL,
                pending_plan_blocked = NULL,
                pending_plan_summary = NULL,
                updated_at = ?
             WHERE stack_name = ?`
        ).run(Date.now(), stackName);
    }

    public touchGitSourceDebounce(stackName: string): void {
        this.db.prepare('UPDATE stack_git_sources SET last_debounce_at = ? WHERE stack_name = ?')
            .run(Date.now(), stackName);
    }

    // --- Scheduled Tasks ---

    public getScheduledTasks(): ScheduledTask[] {
        return this.db.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC').all() as ScheduledTask[];
    }

    public getScheduledTask(id: number): ScheduledTask | undefined {
        return this.db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined;
    }

    public createScheduledTask(task: Omit<ScheduledTask, 'id' | 'creator_user_id'> & { creator_user_id?: number | null }): number {
        const result = this.db.prepare(
            'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, selector_type, selector_value, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        ).run(
            task.name, task.target_type, task.target_id, task.node_id,
            task.action, task.cron_expression, task.enabled, task.created_by,
            task.creator_user_id ?? null,
            task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
            task.last_status, task.last_error, task.prune_targets, task.target_services,
            task.prune_label_filter, task.selector_type ?? null, task.selector_value ?? null,
            task.delete_after_run ?? 0, task.run_at ?? null
        );
        return result.lastInsertRowid as number;
    }

    public updateScheduledTask(id: number, updates: Partial<Omit<ScheduledTask, 'id'>>): void {
        const fields: string[] = [];
        const values: unknown[] = [];

        const map: Record<string, unknown> = {
            name: updates.name, target_type: updates.target_type, target_id: updates.target_id,
            node_id: updates.node_id, action: updates.action, cron_expression: updates.cron_expression,
            enabled: updates.enabled, created_by: updates.created_by, updated_at: updates.updated_at,
            last_run_at: updates.last_run_at, next_run_at: updates.next_run_at,
            last_status: updates.last_status, last_error: updates.last_error,
            prune_targets: updates.prune_targets, target_services: updates.target_services,
            prune_label_filter: updates.prune_label_filter,
            selector_type: updates.selector_type,
            selector_value: updates.selector_value,
            delete_after_run: updates.delete_after_run,
            run_at: updates.run_at,
        };

        // `undefined` means "leave this column unchanged"; an explicit `null`
        // writes SQL NULL. Callers rely on this distinction (e.g. run_at: null
        // clears a one-shot's pin while an omitted run_at preserves it), so do
        // not relax this guard to a truthy or `!= null` check.
        for (const [col, val] of Object.entries(map)) {
            if (val !== undefined) {
                fields.push(`${col} = ?`);
                values.push(val);
            }
        }

        if (fields.length === 0) return;
        values.push(id);
        this.db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
    }

    public deleteScheduledTask(id: number): void {
        this.db.transaction(() => {
            this.db.prepare('DELETE FROM scheduled_task_runs WHERE task_id = ?').run(id);
            this.db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
        })();
    }

    public getDueScheduledTasks(now: number): ScheduledTask[] {
        return this.db.prepare(
            'SELECT * FROM scheduled_tasks WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?'
        ).all(now) as ScheduledTask[];
    }

    public getScheduledTaskRuns(taskId: number, limit = 20, offset = 0): { runs: ScheduledTaskRun[]; total: number } {
        const runs = this.db.prepare(
            'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?'
        ).all(taskId, limit, offset) as ScheduledTaskRun[];
        const { total } = this.db.prepare(
            'SELECT COUNT(*) as total FROM scheduled_task_runs WHERE task_id = ?'
        ).get(taskId) as { total: number };
        return { runs, total };
    }

    public createScheduledTaskRun(run: Omit<ScheduledTaskRun, 'id'>): number {
        const result = this.db.prepare(
            'INSERT INTO scheduled_task_runs (task_id, started_at, completed_at, status, output, error, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
        ).run(run.task_id, run.started_at, run.completed_at, run.status, run.output, run.error, run.triggered_by);
        return result.lastInsertRowid as number;
    }

    public updateScheduledTaskRun(id: number, updates: Partial<Omit<ScheduledTaskRun, 'id' | 'task_id'>>): void {
        const fields: string[] = [];
        const values: unknown[] = [];

        if (updates.completed_at !== undefined) { fields.push('completed_at = ?'); values.push(updates.completed_at); }
        if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
        if (updates.output !== undefined) { fields.push('output = ?'); values.push(updates.output); }
        if (updates.error !== undefined) { fields.push('error = ?'); values.push(updates.error); }

        if (fields.length === 0) return;
        values.push(id);
        this.db.prepare(`UPDATE scheduled_task_runs SET ${fields.join(', ')} WHERE id = ?`).run(...values);
    }

    public getAllScheduledTaskRuns(taskId: number): ScheduledTaskRun[] {
        return this.db.prepare(
            'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC'
        ).all(taskId) as ScheduledTaskRun[];
    }

    public markStaleRunsAsFailed(): number {
        const result = this.db.prepare(
            'UPDATE scheduled_task_runs SET status = ?, completed_at = ?, error = ? WHERE status = ?'
        ).run('failure', Date.now(), 'Server restarted during execution', 'running');
        return result.changes;
    }

    public cleanupOldTaskRuns(retentionDays = 30): void {
        const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
        this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff);
    }

    // --- Vulnerability Scans ---

    public createVulnerabilityScan(
        scan: Omit<VulnerabilityScan, 'id' | 'policy_evaluation'> & {
            policy_evaluation?: string | null;
        },
    ): number {
        const stmt = this.db.prepare(
            `INSERT INTO vulnerability_scans (
                node_id, image_ref, image_digest, scanned_at,
                total_vulnerabilities, critical_count, high_count, medium_count,
                low_count, unknown_count, fixable_count,
                secret_count, misconfig_count, scanners_used,
                highest_severity, os_info, trivy_version, scan_duration_ms,
                triggered_by, status, error, stack_context, policy_evaluation
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        );
        const result = stmt.run(
            scan.node_id,
            scan.image_ref,
            scan.image_digest,
            scan.scanned_at,
            scan.total_vulnerabilities,
            scan.critical_count,
            scan.high_count,
            scan.medium_count,
            scan.low_count,
            scan.unknown_count,
            scan.fixable_count,
            scan.secret_count,
            scan.misconfig_count,
            scan.scanners_used,
            scan.highest_severity,
            scan.os_info,
            scan.trivy_version,
            scan.scan_duration_ms,
            scan.triggered_by,
            scan.status,
            scan.error,
            scan.stack_context,
            scan.policy_evaluation ?? null,
        );
        return result.lastInsertRowid as number;
    }

    public updateVulnerabilityScan(
        id: number,
        updates: Partial<Omit<VulnerabilityScan, 'id'>>,
    ): void {
        const ALLOWED_COLUMNS = new Set([
            'node_id', 'image_ref', 'image_digest', 'scanned_at',
            'total_vulnerabilities', 'critical_count', 'high_count',
            'medium_count', 'low_count', 'unknown_count', 'fixable_count',
            'secret_count', 'misconfig_count', 'scanners_used',
            'highest_severity', 'os_info', 'trivy_version', 'scan_duration_ms',
            'triggered_by', 'status', 'error', 'stack_context', 'policy_evaluation',
        ]);
        const fields: string[] = [];
        const values: unknown[] = [];
        for (const [key, value] of Object.entries(updates)) {
            if (!ALLOWED_COLUMNS.has(key)) continue;
            fields.push(`${key} = ?`);
            values.push(value);
        }
        if (fields.length === 0) return;
        values.push(id);
        this.db
            .prepare(`UPDATE vulnerability_scans SET ${fields.join(', ')} WHERE id = ?`)
            .run(...(values as never[]));
    }

    public getVulnerabilityScan(id: number): VulnerabilityScan | null {
        return (
            (this.db
                .prepare('SELECT * FROM vulnerability_scans WHERE id = ?')
                .get(id) as VulnerabilityScan | undefined) ?? null
        );
    }

    /**
     * Stable scan-history identity: prefer a non-empty digest, else image_ref.
     * TRIM so whitespace-only digests fall back to the reference (config scans,
     * legacy rows). Used for retention partitioning and cap metadata.
     */
    private static readonly SCAN_IDENTITY_SQL =
        `COALESCE(NULLIF(TRIM(image_digest), ''), image_ref)`;

    public getVulnerabilityScans(
        nodeId: number,
        opts: {
            imageRef?: string;
            imageRefLike?: string;
            imageDigest?: string;
            imageIdentityLike?: string;
            status?: VulnScanStatus;
            limit?: number;
            offset?: number;
        } = {},
    ): {
        items: VulnerabilityScan[];
        total: number;
        cappedIdentities: Array<{ key: string; kind: 'digest' | 'ref'; displayRef: string }>;
        perImageLimit: number;
    } {
        const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
        const offset = Math.max(0, opts.offset ?? 0);
        const where = ['node_id = ?'];
        const params: unknown[] = [nodeId];
        if (opts.imageRef) {
            where.push('image_ref = ?');
            params.push(opts.imageRef);
        }
        if (opts.imageDigest) {
            where.push('image_digest = ?');
            params.push(opts.imageDigest);
        }
        // Legacy reference-only substring filter (documented API; keep).
        if (opts.imageRefLike) {
            where.push('image_ref LIKE ?');
            params.push(`%${opts.imageRefLike}%`);
        }
        // Additive OR identity search: tag or digest fragment.
        if (opts.imageIdentityLike) {
            const like = `%${opts.imageIdentityLike}%`;
            where.push('(image_ref LIKE ? OR image_digest LIKE ?)');
            params.push(like, like);
        }
        if (opts.status) {
            where.push('status = ?');
            params.push(opts.status);
        }
        const whereSql = where.join(' AND ');
        const identitySql = DatabaseService.SCAN_IDENTITY_SQL;

        // Grouped history caps rows per digest identity so a hot digest cannot
        // drown out others. Exact imageRef or imageDigest deep-dives bypass the
        // cap so operators can page past retention.
        const applyPerImageCap = !opts.imageRef && !opts.imageDigest;
        const parsedLimit = parseInt(this.getGlobalSettings()['scan_history_per_image_limit'] ?? '50', 10);
        const perImageLimit = parsedLimit > 0 ? parsedLimit : 50;

        if (!applyPerImageCap) {
            const total = (
                this.db
                    .prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`)
                    .get(...(params as never[])) as { cnt: number }
            ).cnt;
            const items = this.db
                .prepare(
                    `SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
                )
                .all(...(params as never[]), limit, offset) as VulnerabilityScan[];
            return { items, total, cappedIdentities: [], perImageLimit };
        }

        const rankedCte = `WITH ranked AS (
            SELECT *, ROW_NUMBER() OVER (PARTITION BY ${identitySql} ORDER BY scanned_at DESC) AS rn
            FROM vulnerability_scans
            WHERE ${whereSql}
        )`;
        const total = (
            this.db
                .prepare(`${rankedCte} SELECT COUNT(*) as cnt FROM ranked WHERE rn <= ?`)
                .get(...(params as never[]), perImageLimit) as { cnt: number }
        ).cnt;
        const items = this.db
            .prepare(
                `${rankedCte} SELECT id, node_id, image_ref, image_digest, scanned_at,
                    total_vulnerabilities, critical_count, high_count, medium_count, low_count,
                    unknown_count, fixable_count, secret_count, misconfig_count, scanners_used,
                    highest_severity, os_info, trivy_version, scan_duration_ms, triggered_by,
                    status, error, stack_context, policy_evaluation
                 FROM ranked WHERE rn <= ?
                 ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
            )
            .all(...(params as never[]), perImageLimit, limit, offset) as VulnerabilityScan[];

        // Cap metadata keyed by digest identity. displayRef is the newest
        // image_ref in the bucket so UI copy stays accurate when multiple tags
        // share one digest. `>=` matches the daily prune keeping each identity
        // at exactly perImageLimit rows.
        const cappedRows = this.db
            .prepare(
                `WITH buckets AS (
                    SELECT
                      ${identitySql} AS identity_key,
                      image_ref,
                      image_digest,
                      COUNT(*) OVER (PARTITION BY ${identitySql}) AS cnt,
                      ROW_NUMBER() OVER (PARTITION BY ${identitySql} ORDER BY scanned_at DESC) AS rn
                    FROM vulnerability_scans
                    WHERE ${whereSql}
                  )
                  SELECT identity_key AS key,
                    CASE WHEN NULLIF(TRIM(image_digest), '') IS NOT NULL THEN 'digest' ELSE 'ref' END AS kind,
                    image_ref AS displayRef
                  FROM buckets
                  WHERE rn = 1 AND cnt >= ?`,
            )
            .all(...(params as never[]), perImageLimit) as Array<{
                key: string;
                kind: 'digest' | 'ref';
                displayRef: string;
            }>;

        return { items, total, cappedIdentities: cappedRows, perImageLimit };
    }

    /**
     * Per-identity scan history pruner. For each (node_id, digest-or-ref
     * identity), keep the newest N scans and delete older rows plus child
     * findings. SQLite FK cascade is not enabled here, so children are deleted
     * explicitly. The subquery is self-contained to stay under
     * SQLITE_MAX_VARIABLE_NUMBER on large first-run backlogs.
     */
    public pruneScanHistoryPerImage(perImageLimit: number): number {
        const limit = Math.max(1, Math.floor(perImageLimit));
        const identitySql = DatabaseService.SCAN_IDENTITY_SQL;
        const overflowSubquery = `SELECT id FROM (
            SELECT id, ROW_NUMBER() OVER (
              PARTITION BY node_id, ${identitySql} ORDER BY scanned_at DESC
            ) AS rn
            FROM vulnerability_scans
          ) WHERE rn > ?`;
        const deleteChild = (table: string) =>
            this.db
                .prepare(`DELETE FROM ${table} WHERE scan_id IN (${overflowSubquery})`)
                .run(limit);
        const deleteParent = this.db.prepare(
            `DELETE FROM vulnerability_scans WHERE id IN (${overflowSubquery})`,
        );

        const txn = this.db.transaction(() => {
            deleteChild('vulnerability_details');
            deleteChild('secret_findings');
            deleteChild('misconfig_findings');
            return deleteParent.run(limit).changes;
        });
        return txn();
    }

    /**
     * Distinct image_refs that have at least one scan row for a node. Used by
     * the orphan-scan reconciler to compare stored scans against the artifacts
     * (images, stacks) that still exist on the host.
     */
    public getDistinctScanImageRefs(nodeId: number): string[] {
        return (
            this.db
                .prepare(
                    'SELECT DISTINCT image_ref FROM vulnerability_scans WHERE node_id = ?',
                )
                .all(nodeId) as Array<{ image_ref: string }>
        ).map((r) => r.image_ref);
    }

    /**
     * Delete every scan (and its findings) for one (node_id, image_ref). Used
     * to purge scans whose artifact is gone. Children are deleted explicitly
     * because SQLite foreign-key cascade is not enabled at the connection
     * level (see pruneScanHistoryPerImage). Returns the parent rows removed;
     * idempotent (0 when nothing matches).
     */
    public deleteScansByImageRef(nodeId: number, imageRef: string): number {
        const idSubquery =
            'SELECT id FROM vulnerability_scans WHERE node_id = ? AND image_ref = ?';
        const deleteChild = (table: string) =>
            this.db
                .prepare(`DELETE FROM ${table} WHERE scan_id IN (${idSubquery})`)
                .run(nodeId, imageRef);
        const deleteParent = this.db.prepare(
            'DELETE FROM vulnerability_scans WHERE node_id = ? AND image_ref = ?',
        );
        const txn = this.db.transaction(() => {
            deleteChild('vulnerability_details');
            deleteChild('secret_findings');
            deleteChild('misconfig_findings');
            return deleteParent.run(nodeId, imageRef).changes;
        });
        return txn();
    }

    /**
     * Purge the compose-config (misconfig) scans for a deleted stack, keyed by
     * the `stack:<name>` image_ref convention used by scanComposeStack.
     */
    public deleteStackScans(nodeId: number, stackName: string): number {
        return this.deleteScansByImageRef(nodeId, `stack:${stackName}`);
    }

    public getLatestScanForImage(
        nodeId: number,
        imageRef: string,
    ): VulnerabilityScan | null {
        return (
            (this.db
                .prepare(
                    'SELECT * FROM vulnerability_scans WHERE node_id = ? AND image_ref = ? ORDER BY scanned_at DESC LIMIT 1',
                )
                .get(nodeId, imageRef) as VulnerabilityScan | undefined) ?? null
        );
    }

    public getLatestScanSummaryByImageRefs(
        nodeId: number,
        imageRefs: string[],
    ): Map<string, { total: number; critical: number; high: number; scannedAt: number }> {
        const summary = new Map<string, { total: number; critical: number; high: number; scannedAt: number }>();
        if (imageRefs.length === 0) return summary;

        const placeholders = imageRefs.map(() => '?').join(',');
        const rows = this.db
            .prepare(
                `SELECT image_ref, total_vulnerabilities, critical_count, high_count, scanned_at
                 FROM vulnerability_scans v1
                 WHERE node_id = ?
                   AND image_ref IN (${placeholders})
                   AND scanned_at = (
                     SELECT MAX(scanned_at) FROM vulnerability_scans v2
                     WHERE v2.node_id = v1.node_id AND v2.image_ref = v1.image_ref
                   )`,
            )
            .all(nodeId, ...imageRefs) as Array<{
                image_ref: string;
                total_vulnerabilities: number;
                critical_count: number;
                high_count: number;
                scanned_at: number;
            }>;

        for (const row of rows) {
            summary.set(row.image_ref, {
                total: row.total_vulnerabilities,
                critical: row.critical_count,
                high: row.high_count,
                scannedAt: row.scanned_at,
            });
        }
        return summary;
    }

    public getLatestScanByDigest(digest: string, scannersUsed?: string): VulnerabilityScan | null {
        if (!digest) return null;
        if (scannersUsed) {
            return (
                (this.db
                    .prepare(
                        "SELECT * FROM vulnerability_scans WHERE image_digest = ? AND scanners_used = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
                    )
                    .get(digest, scannersUsed) as VulnerabilityScan | undefined) ?? null
            );
        }
        return (
            (this.db
                .prepare(
                    "SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
                )
                .get(digest) as VulnerabilityScan | undefined) ?? null
        );
    }

    /**
     * Latest completed vulnerability-bearing scan for a digest ON A SPECIFIC NODE.
     *
     * Unlike getLatestScanByDigest, this filters by node_id so a read-only
     * response never surfaces a scan (or scan id) produced on another node, and
     * it restricts to scanner sets that actually ran the vulnerability scanner so
     * a secret-only or config scan is not mistaken for a clean vuln scan.
     */
    public getLatestVulnScanByDigestForNode(digest: string, nodeId: number): VulnerabilityScan | null {
        if (!digest) return null;
        const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
        return (
            (this.db
                .prepare(
                    `SELECT * FROM vulnerability_scans WHERE image_digest = ? AND node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders}) ORDER BY scanned_at DESC LIMIT 1`,
                )
                .get(digest, nodeId, ...VULN_BEARING_SCANNER_SETS) as VulnerabilityScan | undefined) ?? null
        );
    }

    public deleteOldScans(olderThanMs: number): number {
        const cutoff = Date.now() - olderThanMs;
        const result = this.db
            .prepare('DELETE FROM vulnerability_scans WHERE scanned_at < ?')
            .run(cutoff);
        return result.changes;
    }

    public markStaleScansAsFailed(olderThanMs: number): number {
        const cutoff = Date.now() - olderThanMs;
        const result = this.db
            .prepare(
                `UPDATE vulnerability_scans
                 SET status = 'failed',
                     error = 'Scan did not complete within expected time',
                     scan_duration_ms = ? - scanned_at
                 WHERE status = 'in_progress' AND scanned_at < ?`,
            )
            .run(Date.now(), cutoff);
        return result.changes;
    }

    public isImageBeingScanned(nodeId: number, imageRef: string): boolean {
        const row = this.db
            .prepare(
                "SELECT id FROM vulnerability_scans WHERE node_id = ? AND image_ref = ? AND status = 'in_progress' LIMIT 1",
            )
            .get(nodeId, imageRef);
        return !!row;
    }

    public insertVulnerabilityDetails(
        scanId: number,
        details: Array<Omit<VulnerabilityDetail, 'id' | 'scan_id'>>,
    ): void {
        if (details.length === 0) return;
        const stmt = this.db.prepare(
            `INSERT INTO vulnerability_details (
                scan_id, vulnerability_id, pkg_name, installed_version,
                fixed_version, severity, title, description, primary_url,
                status, cvss_score, cvss_vector, cvss_source, vendor_severity,
                purl, pkg_path, layer_digest
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        );
        const txn = this.db.transaction((rows: typeof details) => {
            for (const d of rows) {
                stmt.run(
                    scanId,
                    d.vulnerability_id,
                    d.pkg_name,
                    d.installed_version,
                    d.fixed_version,
                    d.severity,
                    d.title,
                    d.description,
                    d.primary_url,
                    d.status ?? null,
                    d.cvss_score ?? null,
                    d.cvss_vector ?? null,
                    d.cvss_source ?? null,
                    d.vendor_severity ?? null,
                    d.purl ?? null,
                    d.pkg_path ?? null,
                    d.layer_digest ?? null,
                );
            }
        });
        txn(details);
    }

    public getVulnerabilityDetails(
        scanId: number,
        opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
    ): { items: VulnerabilityDetail[]; total: number } {
        const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
        const offset = Math.max(0, opts.offset ?? 0);
        const where = ['scan_id = ?'];
        const params: unknown[] = [scanId];
        if (opts.severity) {
            where.push('severity = ?');
            params.push(opts.severity);
        }
        const whereSql = where.join(' AND ');
        const total = (
            this.db
                .prepare(`SELECT COUNT(*) as cnt FROM vulnerability_details WHERE ${whereSql}`)
                .get(...(params as never[])) as { cnt: number }
        ).cnt;
        const severityOrder = `CASE severity
            WHEN 'CRITICAL' THEN 0
            WHEN 'HIGH' THEN 1
            WHEN 'MEDIUM' THEN 2
            WHEN 'LOW' THEN 3
            ELSE 4 END`;
        const items = this.db
            .prepare(
                `SELECT * FROM vulnerability_details WHERE ${whereSql} ORDER BY ${severityOrder}, pkg_name LIMIT ? OFFSET ?`,
            )
            .all(...(params as never[]), limit, offset) as VulnerabilityDetail[];
        return { items, total };
    }

    /**
     * All findings for a scan, unpaginated. Used by the pre-deploy policy gate
     * to re-derive severity from suppression-filtered findings, where every row
     * must be considered rather than a single display page.
     */
    public getAllVulnerabilityDetails(scanId: number): VulnerabilityDetail[] {
        return this.db
            .prepare('SELECT * FROM vulnerability_details WHERE scan_id = ?')
            .all(scanId) as VulnerabilityDetail[];
    }

    public insertSecretFindings(
        scanId: number,
        findings: Array<Omit<SecretFinding, 'id' | 'scan_id'>>,
    ): void {
        if (findings.length === 0) return;
        const stmt = this.db.prepare(
            `INSERT INTO secret_findings (
                scan_id, rule_id, category, severity, title, target, start_line, end_line, match_excerpt
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        );
        const txn = this.db.transaction((rows: typeof findings) => {
            for (const f of rows) {
                stmt.run(
                    scanId,
                    f.rule_id,
                    f.category,
                    f.severity,
                    f.title,
                    f.target,
                    f.start_line,
                    f.end_line,
                    f.match_excerpt,
                );
            }
        });
        txn(findings);
    }

    public getSecretFindings(
        scanId: number,
        opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
    ): { items: SecretFinding[]; total: number } {
        const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
        const offset = Math.max(0, opts.offset ?? 0);
        const where = ['scan_id = ?'];
        const params: unknown[] = [scanId];
        if (opts.severity) {
            where.push('severity = ?');
            params.push(opts.severity);
        }
        const whereSql = where.join(' AND ');
        const total = (
            this.db
                .prepare(`SELECT COUNT(*) as cnt FROM secret_findings WHERE ${whereSql}`)
                .get(...(params as never[])) as { cnt: number }
        ).cnt;
        const severityOrder = `CASE severity
            WHEN 'CRITICAL' THEN 0
            WHEN 'HIGH' THEN 1
            WHEN 'MEDIUM' THEN 2
            WHEN 'LOW' THEN 3
            ELSE 4 END`;
        const items = this.db
            .prepare(
                `SELECT * FROM secret_findings WHERE ${whereSql} ORDER BY ${severityOrder}, target LIMIT ? OFFSET ?`,
            )
            .all(...(params as never[]), limit, offset) as SecretFinding[];
        return { items, total };
    }

    public insertMisconfigFindings(
        scanId: number,
        findings: Array<Omit<MisconfigFinding, 'id' | 'scan_id'>>,
    ): void {
        if (findings.length === 0) return;
        const stmt = this.db.prepare(
            `INSERT INTO misconfig_findings (
                scan_id, rule_id, check_id, severity, title, message, resolution, target, primary_url
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        );
        const txn = this.db.transaction((rows: typeof findings) => {
            for (const f of rows) {
                stmt.run(
                    scanId,
                    f.rule_id,
                    f.check_id,
                    f.severity,
                    f.title,
                    f.message,
                    f.resolution,
                    f.target,
                    f.primary_url,
                );
            }
        });
        txn(findings);
    }

    public getMisconfigFindings(
        scanId: number,
        opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
    ): { items: MisconfigFinding[]; total: number } {
        const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
        const offset = Math.max(0, opts.offset ?? 0);
        const where = ['scan_id = ?'];
        const params: unknown[] = [scanId];
        if (opts.severity) {
            where.push('severity = ?');
            params.push(opts.severity);
        }
        const whereSql = where.join(' AND ');
        const total = (
            this.db
                .prepare(`SELECT COUNT(*) as cnt FROM misconfig_findings WHERE ${whereSql}`)
                .get(...(params as never[])) as { cnt: number }
        ).cnt;
        const severityOrder = `CASE severity
            WHEN 'CRITICAL' THEN 0
            WHEN 'HIGH' THEN 1
            WHEN 'MEDIUM' THEN 2
            WHEN 'LOW' THEN 3
            ELSE 4 END`;
        const items = this.db
            .prepare(
                `SELECT * FROM misconfig_findings WHERE ${whereSql} ORDER BY ${severityOrder}, target LIMIT ? OFFSET ?`,
            )
            .all(...(params as never[]), limit, offset) as MisconfigFinding[];
        return { items, total };
    }

    public getImageScanSummaries(nodeId: number): Record<string, ScanSummary> {
        // The per-image row is the latest scan overall (so a secret-only or
        // compose/config scan still contributes its secret/misconfig counts and
        // staleness), but the vulnerability counts are sourced from the latest
        // VULN-bearing scan via a LEFT JOIN. Without this, a newer secret-only
        // node scan would clobber an image's Critical/High posture to zero.
        const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
        const rows = this.db
            .prepare(
                `SELECT base.image_ref, base.scanned_at,
                    base.secret_count, base.misconfig_count,
                    COALESCE(vuln.scan_id, base.id) AS scan_id,
                    COALESCE(vuln.highest_severity, base.highest_severity) AS highest_severity,
                    COALESCE(vuln.total_vulnerabilities, 0) AS total_vulnerabilities,
                    COALESCE(vuln.critical_count, 0) AS critical_count,
                    COALESCE(vuln.high_count, 0) AS high_count,
                    COALESCE(vuln.medium_count, 0) AS medium_count,
                    COALESCE(vuln.low_count, 0) AS low_count,
                    COALESCE(vuln.unknown_count, 0) AS unknown_count,
                    COALESCE(vuln.fixable_count, 0) AS fixable_count
                 FROM vulnerability_scans base
                 INNER JOIN (
                   SELECT image_ref, MAX(scanned_at) AS max_scanned
                   FROM vulnerability_scans
                   WHERE node_id = ? AND status = 'completed'
                   GROUP BY image_ref
                 ) latest ON latest.image_ref = base.image_ref AND latest.max_scanned = base.scanned_at
                 LEFT JOIN (
                   SELECT v.image_ref, v.id AS scan_id, v.highest_severity, v.total_vulnerabilities, v.critical_count,
                          v.high_count, v.medium_count, v.low_count, v.unknown_count, v.fixable_count
                   FROM vulnerability_scans v
                   INNER JOIN (
                     SELECT image_ref, MAX(scanned_at) AS max_scanned
                     FROM vulnerability_scans
                     WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
                     GROUP BY image_ref
                   ) lv ON lv.image_ref = v.image_ref AND lv.max_scanned = v.scanned_at
                   WHERE v.node_id = ? AND v.status = 'completed' AND v.scanners_used IN (${placeholders})
                 ) vuln ON vuln.image_ref = base.image_ref
                 WHERE base.node_id = ? AND base.status = 'completed'
                   AND base.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'`,
            )
            .all(nodeId, nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId) as Array<{
                image_ref: string;
                scan_id: number;
                highest_severity: VulnSeverity | null;
                total_vulnerabilities: number;
                critical_count: number;
                high_count: number;
                medium_count: number;
                low_count: number;
                unknown_count: number;
                fixable_count: number;
                secret_count: number;
                misconfig_count: number;
                scanned_at: number;
            }>;
        const out: Record<string, ScanSummary> = {};
        for (const r of rows) {
            out[r.image_ref] = {
                image_ref: r.image_ref,
                highest_severity: r.highest_severity,
                total: r.total_vulnerabilities,
                critical: r.critical_count,
                high: r.high_count,
                medium: r.medium_count,
                low: r.low_count,
                unknown: r.unknown_count,
                fixable: r.fixable_count,
                secret_count: r.secret_count,
                misconfig_count: r.misconfig_count,
                scanned_at: r.scanned_at,
                scan_id: r.scan_id,
            };
        }
        return out;
    }

    /**
     * Critical/High vulnerability findings from the latest completed scan per
     * image on a node, for read-time posture math (suppression-aware fixable and
     * accepted counts). Selects only the identity columns posture needs and is
     * capped: `truncated` is set when the cap is hit so the caller can mark the
     * posture partial rather than silently undercount. Phase 2 intentionally
     * omits `status` (added by the findings-enrichment phase) so this runs
     * standalone against a not-yet-migrated `vulnerability_details`.
     */
    public getLatestCritHighVulnFindingsForNode(
        nodeId: number,
        limit = 5000,
    ): {
        items: Array<{ image_ref: string; vulnerability_id: string; pkg_name: string; fixed_version: string | null }>;
        truncated: boolean;
    } {
        const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
        const rows = this.db
            .prepare(
                `SELECT vs.image_ref, vd.vulnerability_id, vd.pkg_name, vd.fixed_version
                 FROM vulnerability_details vd
                 INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id
                 INNER JOIN (
                   SELECT image_ref, MAX(scanned_at) AS max_scanned
                   FROM vulnerability_scans
                   WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
                     AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
                   GROUP BY image_ref
                 ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
                 WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
                   AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
                   AND vd.severity IN ('CRITICAL', 'HIGH')
                 LIMIT ?`,
            )
            .all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{
                image_ref: string;
                vulnerability_id: string;
                pkg_name: string;
                fixed_version: string | null;
            }>;
        const truncated = rows.length > limit;
        return { items: truncated ? rows.slice(0, limit) : rows, truncated };
    }

    /**
     * Known-exploited (CISA KEV) findings at ANY severity from the latest
     * completed vuln-bearing scan per image, for the `knownExploited` posture
     * fact. The deploy gate blocks a KEV regardless of severity, so this is not
     * restricted to Critical/High the way the fixable/triage helpers are.
     * Suppression filtering is applied by the caller at read time. Same bounded
     * shape as `getLatestCritHighVulnFindingsForNode`.
     */
    public getLatestKevFindingsForNode(
        nodeId: number,
        limit = 5000,
    ): {
        items: Array<{ image_ref: string; vulnerability_id: string; pkg_name: string; fixed_version: string | null }>;
        truncated: boolean;
    } {
        const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
        const rows = this.db
            .prepare(
                `SELECT vs.image_ref, vd.vulnerability_id, vd.pkg_name, vd.fixed_version
                 FROM vulnerability_details vd
                 INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id
                 INNER JOIN cve_intel ci ON ci.cve_id = vd.vulnerability_id AND ci.kev = 1
                 INNER JOIN (
                   SELECT image_ref, MAX(scanned_at) AS max_scanned
                   FROM vulnerability_scans
                   WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
                     AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
                   GROUP BY image_ref
                 ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
                 WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
                   AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
                 LIMIT ?`,
            )
            .all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{
                image_ref: string;
                vulnerability_id: string;
                pkg_name: string;
                fixed_version: string | null;
            }>;
        const truncated = rows.length > limit;
        return { items: truncated ? rows.slice(0, limit) : rows, truncated };
    }

    /**
     * High-severity misconfiguration findings from the latest completed scan per
     * image on a node, for the acknowledgement-aware `dangerousCompose` posture
     * fact. Same bounded shape as `getLatestCritHighVulnFindingsForNode`.
     */
    public getLatestHighMisconfigFindingsForNode(
        nodeId: number,
        limit = 5000,
    ): { items: Array<{ rule_id: string; stack_context: string | null }>; truncated: boolean } {
        const rows = this.db
            .prepare(
                `SELECT mf.rule_id, vs.stack_context
                 FROM misconfig_findings mf
                 INNER JOIN vulnerability_scans vs ON vs.id = mf.scan_id
                 INNER JOIN (
                   SELECT image_ref, MAX(scanned_at) AS max_scanned
                   FROM vulnerability_scans
                   WHERE node_id = ? AND status = 'completed'
                   GROUP BY image_ref
                 ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
                 WHERE vs.node_id = ? AND vs.status = 'completed'
                   AND mf.severity IN ('CRITICAL', 'HIGH')
                 LIMIT ?`,
            )
            .all(nodeId, nodeId, limit + 1) as Array<{ rule_id: string; stack_context: string | null }>;
        const truncated = rows.length > limit;
        return { items: truncated ? rows.slice(0, limit) : rows, truncated };
    }

    /**
     * Critical/High findings (plus known-exploited findings at any severity)
     * from the latest completed scan per image, with the severity + CVSS the
     * overview's exploit-intel charts need. Same bounded shape as
     * getLatestCritHighVulnFindingsForNode (single latest-per-image JOIN, capped,
     * `truncated` flagged). A KEV finding gates a deploy regardless of its
     * severity, so a Medium/Low KEV is selected here (via the cve_intel join) to
     * match the gate. Intel (KEV/EPSS) and suppression filtering are applied by
     * the caller at read time.
     */
    public getLatestCritHighFindingsWithCvssForNode(
        nodeId: number,
        limit = 2000,
    ): {
        items: Array<{
            image_ref: string;
            scan_id: number;
            vulnerability_id: string;
            pkg_name: string;
            severity: VulnSeverity;
            cvss_score: number | null;
            fixed_version: string | null;
        }>;
        truncated: boolean;
    } {
        const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
        const rows = this.db
            .prepare(
                `SELECT vs.image_ref, vs.id AS scan_id, vd.vulnerability_id, vd.pkg_name,
                        vd.severity, vd.cvss_score, vd.fixed_version
                 FROM vulnerability_details vd
                 INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id
                 LEFT JOIN cve_intel ci ON ci.cve_id = vd.vulnerability_id
                 INNER JOIN (
                   SELECT image_ref, MAX(scanned_at) AS max_scanned
                   FROM vulnerability_scans
                   WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
                     AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
                   GROUP BY image_ref
                 ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
                 WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
                   AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
                   AND (vd.severity IN ('CRITICAL', 'HIGH') OR ci.kev = 1)
                 -- Rank by the same exploitation-risk tiers the overview list
                 -- displays (SecurityCharts exploitTier, "assume it's automatable"):
                 -- known-exploited (KEV) > elevated EPSS > unknown EPSS > known-low
                 -- EPSS. A finding with no EPSS evidence outranks one shown unlikely,
                 -- so when the cap truncates it keeps the rows the list ranks highest.
                 -- EPSS desc then CVSS desc break ties within a tier.
                 ORDER BY
                   CASE
                     WHEN ci.kev = 1 THEN 0
                     WHEN ci.epss_score IS NULL THEN 2
                     WHEN ci.epss_score >= ${HIGH_EPSS_THRESHOLD} THEN 1
                     ELSE 3
                   END,
                   COALESCE(ci.epss_score, -1) DESC,
                   COALESCE(vd.cvss_score, -1) DESC
                 LIMIT ?`,
            )
            .all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{
                image_ref: string;
                scan_id: number;
                vulnerability_id: string;
                pkg_name: string;
                severity: VulnSeverity;
                cvss_score: number | null;
                fixed_version: string | null;
            }>;
        const truncated = rows.length > limit;
        return { items: truncated ? rows.slice(0, limit) : rows, truncated };
    }

    /**
     * Distinct CVE ids present in stored findings, for the intel service to fetch
     * EPSS only for what exists (EPSS covers CVEs, not GHSA, so filter to CVE-*).
     */
    public getDistinctVulnerabilityCveIds(limit = 20000): string[] {
        const rows = this.db
            .prepare(
                `SELECT DISTINCT vulnerability_id FROM vulnerability_details
                 WHERE vulnerability_id LIKE 'CVE-%' LIMIT ?`,
            )
            .all(limit) as Array<{ vulnerability_id: string }>;
        return rows.map((r) => r.vulnerability_id);
    }

    /**
     * Replace the KEV membership set. Clears kev on every row first, then marks
     * the supplied CVEs, so a CVE removed from CISA's feed stops being flagged.
     * Preserves EPSS columns (ON CONFLICT only touches the kev fields).
     */
    public replaceKev(entries: Array<{ cve_id: string; date_added: string | null }>, now: number): void {
        const clear = this.db.prepare('UPDATE cve_intel SET kev = 0');
        const upsert = this.db.prepare(
            `INSERT INTO cve_intel (cve_id, kev, kev_date, updated_at)
             VALUES (?, 1, ?, ?)
             ON CONFLICT(cve_id) DO UPDATE SET kev = 1, kev_date = excluded.kev_date, updated_at = excluded.updated_at`,
        );
        const txn = this.db.transaction((rows: typeof entries) => {
            clear.run();
            for (const e of rows) upsert.run(e.cve_id, e.date_added, now);
        });
        txn(entries);
    }

    /** Upsert EPSS scores. Preserves kev columns (ON CONFLICT touches only EPSS). */
    public upsertEpss(entries: Array<{ cve_id: string; epss_score: number; epss_percentile: number }>, now: number): void {
        if (entries.length === 0) return;
        const upsert = this.db.prepare(
            `INSERT INTO cve_intel (cve_id, epss_score, epss_percentile, updated_at)
             VALUES (?, ?, ?, ?)
             ON CONFLICT(cve_id) DO UPDATE SET epss_score = excluded.epss_score,
               epss_percentile = excluded.epss_percentile, updated_at = excluded.updated_at`,
        );
        const txn = this.db.transaction((rows: typeof entries) => {
            for (const e of rows) upsert.run(e.cve_id, e.epss_score, e.epss_percentile, now);
        });
        txn(entries);
    }

    /**
     * Read-time intel join. Returns a map keyed by CVE id for the supplied ids
     * only (chunked to stay under SQLite's bound-parameter ceiling). Absent ids
     * simply have no entry.
     */
    public getCveIntel(cveIds: string[]): Map<string, CveIntel> {
        const out = new Map<string, CveIntel>();
        if (cveIds.length === 0) return out;
        const unique = [...new Set(cveIds)];
        const CHUNK = 900;
        for (let i = 0; i < unique.length; i += CHUNK) {
            const chunk = unique.slice(i, i + CHUNK);
            const placeholders = chunk.map(() => '?').join(', ');
            const rows = this.db
                .prepare(
                    `SELECT cve_id, kev, kev_date, epss_score, epss_percentile
                     FROM cve_intel WHERE cve_id IN (${placeholders})`,
                )
                .all(...chunk) as Array<{
                    cve_id: string;
                    kev: number;
                    kev_date: string | null;
                    epss_score: number | null;
                    epss_percentile: number | null;
                }>;
            for (const r of rows) {
                out.set(r.cve_id, {
                    kev: r.kev === 1,
                    kevDate: r.kev_date,
                    epssScore: r.epss_score,
                    epssPercentile: r.epss_percentile,
                });
            }
        }
        return out;
    }

    /**
     * Uncapped count of scans in a given status for a node. Unlike
     * `getVulnerabilityScans`, this never applies the per-image history cap, so
     * the Security overview reports the true number of (for example) failed
     * scans rather than a capped grouped total.
     */
    public countScansByStatus(nodeId: number, status: VulnScanStatus): number {
        return (
            this.db
                .prepare(
                    'SELECT COUNT(*) AS cnt FROM vulnerability_scans WHERE node_id = ? AND status = ?',
                )
                .get(nodeId, status) as { cnt: number }
        ).cnt;
    }

    /**
     * Daily Critical/High totals for the node over the last `days` days, for the
     * Security overview risk-trend chart. For each calendar day with scans, takes
     * the latest completed scan per image (so a re-scan replaces, not adds) and
     * sums the critical and high counts across images. Days with no scans are
     * omitted from the result.
     */
    public getDailyRiskTrend(
        nodeId: number,
        days = 30,
    ): Array<{ date: string; critical: number; high: number }> {
        const window = Math.max(1, Math.min(days, 365));
        const cutoffMs = Date.now() - window * 24 * 60 * 60 * 1000;
        const rows = this.db
            .prepare(
                `WITH daily_latest AS (
                   SELECT
                     DATE(scanned_at / 1000, 'unixepoch') AS day,
                     image_ref,
                     critical_count,
                     high_count,
                     ROW_NUMBER() OVER (
                       PARTITION BY DATE(scanned_at / 1000, 'unixepoch'), image_ref
                       ORDER BY scanned_at DESC
                     ) AS rn
                   FROM vulnerability_scans
                   WHERE node_id = ? AND status = 'completed' AND scanned_at >= ?
                     AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'
                 )
                 SELECT day,
                        SUM(critical_count) AS critical,
                        SUM(high_count) AS high
                 FROM daily_latest
                 WHERE rn = 1
                 GROUP BY day
                 ORDER BY day ASC`,
            )
            .all(nodeId, cutoffMs) as Array<{ day: string; critical: number; high: number }>;
        return rows.map((r) => ({ date: r.day, critical: r.critical ?? 0, high: r.high ?? 0 }));
    }

    /**
     * Count of enabled block-on-deploy policies that are eligible to apply to
     * this node: fleet-wide (node_id IS NULL) or scoped to this node. Built on
     * `getScanPoliciesForUi` so a replica never counts policies scoped to a
     * sibling node's identity. Stack-pattern applicability is not evaluated
     * (there is no concrete stack name at overview scope), so this is an
     * approximate "is this node enforcing" indicator, not a per-stack guarantee.
     */
    public countEligibleBlockPolicies(
        nodeId: number,
        role: 'control' | 'replica',
        selfIdentity: string,
    ): number {
        return this.getScanPoliciesForUi(role, selfIdentity).filter(
            (p) =>
                p.enabled === 1 &&
                p.block_on_deploy === 1 &&
                (p.node_id === null || p.node_id === nodeId),
        ).length;
    }

    // --- Scan Policies ---

    public getScanPolicies(): ScanPolicy[] {
        return this.db
            .prepare('SELECT * FROM scan_policies ORDER BY created_at DESC')
            .all() as ScanPolicy[];
    }

    /**
     * Local-only scan policies (created on this instance, not replicated from a
     * control). Used by the fleet sync sender so it never re-replicates rows
     * that came from a control in the first place.
     */
    public getLocalScanPolicies(): ScanPolicy[] {
        return this.db
            .prepare('SELECT * FROM scan_policies WHERE replicated_from_control = 0 ORDER BY created_at DESC')
            .all() as ScanPolicy[];
    }

    /**
     * Variant of `getScanPolicies` for the security-settings UI.
     *
     * On a control instance: returns the full set, identical to
     * `getScanPolicies`.
     *
     * On a replica: returns only the policies that apply to THIS replica.
     * Replicated rows scoped to a different replica's identity (the
     * `node_identity` of a sibling node in the fleet) are filtered out so
     * an operator on Replica A cannot enumerate the names of identity-scoped
     * policies meant for Replica B. Internal evaluators
     * (`getMatchingPolicy`, `evaluateScanAgainstPolicies`) keep using the
     * unfiltered list because they already enforce identity matching at
     * evaluation time.
     */
    public getScanPoliciesForUi(role: 'control' | 'replica', selfIdentity: string): ScanPolicy[] {
        const all = this.getScanPolicies();
        if (role === 'control') return all;
        return all.filter((p) => {
            if (p.replicated_from_control === 0) return true;
            // Fleet-wide replicated rows have an empty node_identity and
            // apply on every replica.
            if (!p.node_identity) return true;
            return p.node_identity === selfIdentity;
        });
    }

    public getScanPolicy(id: number): ScanPolicy | null {
        return (
            (this.db
                .prepare('SELECT * FROM scan_policies WHERE id = ?')
                .get(id) as ScanPolicy | undefined) ?? null
        );
    }

    public createScanPolicy(
        policy: Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>,
    ): ScanPolicy {
        const now = Date.now();
        const result = this.db
            .prepare(
                `INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable, replicated_from_control, created_at, updated_at)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
            )
            .run(
                policy.name,
                policy.node_id,
                policy.node_identity ?? '',
                policy.stack_pattern,
                policy.max_severity,
                policy.block_on_deploy,
                policy.enabled,
                policy.block_on_severity,
                policy.block_on_kev,
                policy.block_on_fixable,
                policy.replicated_from_control ?? 0,
                now,
                now,
            );
        return {
            ...policy,
            node_identity: policy.node_identity ?? '',
            replicated_from_control: policy.replicated_from_control ?? 0,
            id: result.lastInsertRowid as number,
            created_at: now,
            updated_at: now,
        };
    }

    public updateScanPolicy(
        id: number,
        updates: Partial<Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>>,
    ): ScanPolicy | null {
        const existing = this.getScanPolicy(id);
        if (!existing) return null;
        const ALLOWED_COLUMNS = new Set([
            'name', 'node_id', 'node_identity', 'stack_pattern', 'max_severity',
            'block_on_deploy', 'enabled', 'block_on_severity', 'block_on_kev',
            'block_on_fixable', 'replicated_from_control',
        ]);
        const fields: string[] = [];
        const values: unknown[] = [];
        for (const [key, value] of Object.entries(updates)) {
            if (!ALLOWED_COLUMNS.has(key)) continue;
            fields.push(`${key} = ?`);
            values.push(value);
        }
        if (fields.length === 0) return existing;
        fields.push('updated_at = ?');
        values.push(Date.now());
        values.push(id);
        this.db
            .prepare(`UPDATE scan_policies SET ${fields.join(', ')} WHERE id = ?`)
            .run(...(values as never[]));
        return this.getScanPolicy(id);
    }

    public deleteScanPolicy(id: number): void {
        // policy_evaluation is a JSON blob containing the policyId of the policy
        // that produced it. Clear it on every scan referencing the deleted policy
        // so cached scans no longer report stale violations after the policy is gone.
        const clearEval = this.db.prepare(
            `UPDATE vulnerability_scans
                SET policy_evaluation = NULL
              WHERE json_extract(policy_evaluation, '$.policyId') = ?`,
        );
        const deletePolicy = this.db.prepare('DELETE FROM scan_policies WHERE id = ?');
        const txn = this.db.transaction((policyId: number) => {
            clearEval.run(policyId);
            deletePolicy.run(policyId);
        });
        txn(id);
    }

    /**
     * Replace all policies that were replicated from a control node with the
     * provided rows in a single transaction. Local-only policies (created on
     * this instance directly) are left untouched.
     *
     * Replicated policies always insert with fresh ids on the replica, so any
     * `vulnerability_scans.policy_evaluation` row pointing at a replicated
     * policy from the previous push refers to a now-deleted id. Clear those
     * orphaned cache entries inside the same transaction so a replica's UI
     * stops showing violations from a policy that no longer exists.
     */
    public replaceReplicatedScanPolicies(rows: ScanPolicy[]): void {
        const now = Date.now();
        const deleteStmt = this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1');
        const insertStmt = this.db.prepare(
            `INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable, replicated_from_control, created_at, updated_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
        );
        const txn = this.db.transaction((policies: ScanPolicy[]) => {
            deleteStmt.run();
            for (const p of policies) {
                insertStmt.run(
                    p.name,
                    null,
                    p.node_identity ?? '',
                    p.stack_pattern,
                    p.max_severity,
                    p.block_on_deploy,
                    p.enabled,
                    // A legacy control omits the risk columns; default replicated rows
                    // to severity-only so an older control's intent is preserved.
                    p.block_on_severity ?? 1,
                    p.block_on_kev ?? 0,
                    p.block_on_fixable ?? 0,
                    p.created_at ?? now,
                    p.updated_at ?? now,
                );
            }
            this.clearOrphanPolicyEvaluations();
        });
        txn(rows);
    }

    public getMatchingPolicy(
        nodeId: number,
        stackName: string | null,
        selfIdentity: string,
    ): ScanPolicy | null {
        // Filter on node_id at SQL: rows are eligible when fleet-wide
        // (node_id IS NULL) or when locally scoped to this node (node_id = ?).
        // Replicated rows always insert node_id = NULL (see
        // replaceReplicatedScanPolicies), so identity scoping for replicated
        // rows is enforced in `matchesIdentity` below, not in SQL.
        const policies = this.db
            .prepare(
                'SELECT * FROM scan_policies WHERE enabled = 1 AND (node_id IS NULL OR node_id = ?)',
            )
            .all(nodeId) as ScanPolicy[];
        const matchesStack = (pattern: string | null): boolean => {
            if (!pattern) return true;
            if (!stackName) return false;
            return stackPatternMatches(stackName, pattern);
        };
        const matchesIdentity = (p: ScanPolicy): boolean => {
            // Locally created policies (never replicated) apply based on node_id logic already filtered.
            if (p.replicated_from_control === 0) return true;
            // Replicated policies without a specific identity are fleet-wide.
            if (!p.node_identity) return true;
            // Identity-scoped replicated policies only apply to their target instance.
            return p.node_identity === selfIdentity;
        };
        const scoped = policies.filter((p) => matchesStack(p.stack_pattern) && matchesIdentity(p));
        if (scoped.length === 0) return null;
        const isNodeScoped = (p: ScanPolicy): boolean => Boolean(p.node_id) || Boolean(p.node_identity);
        scoped.sort((a, b) => {
            const aNode = isNodeScoped(a);
            const bNode = isNodeScoped(b);
            if (aNode && !bNode) return -1;
            if (!aNode && bNode) return 1;
            if (a.stack_pattern && !b.stack_pattern) return -1;
            if (!a.stack_pattern && b.stack_pattern) return 1;
            // Deterministic tiebreaker: lowest id wins. Two rows in the same
            // scope class (e.g. both fleet-wide stack-wildcard) must resolve
            // to the same policy on every replica, regardless of SQLite row
            // iteration order.
            return a.id - b.id;
        });
        return scoped[0];
    }

    /**
     * Evaluate a completed scan against the matching policy for its node and
     * stack context. Returns the evaluation that should be persisted to the
     * scan row, or null when no policy matches.
     *
     * The result is informational. `violated=false` means a policy matched
     * but the scan was within limits; the UI surfaces a banner only when
     * `violated=true`. Blocking enforcement lives in the pre-deploy gate.
     */
    public evaluateScanAgainstPolicies(
        nodeId: number,
        scan: VulnerabilityScan,
        selfIdentity: string,
    ): PolicyEvaluation | null {
        const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity);
        if (!policy) return null;
        const inputs = policyInputs(policy);
        // The pre-deploy gate filters suppressed findings when this setting is on,
        // so the informational banner honors them too; otherwise the banner could
        // claim a violation the gate would not enforce. With it off, both score the
        // raw findings. (The gate's truncation fail-closed rule stays gate-only;
        // the banner reflects the findings it can read and the gate is
        // authoritative for blocking.)
        const honorSuppressions = this.getGlobalSettings()['deploy_block_honor_suppressions'] === '1';
        let reasons: PolicyBlockReason[];
        const needsDetails = inputs.blockOnKev || inputs.blockOnFixable || honorSuppressions;
        if (!needsDetails) {
            // Severity-only banner from the stored aggregate: no per-finding read.
            const severityHit = inputs.blockOnSeverity && isSeverityAtLeast(scan.highest_severity, policy.max_severity);
            reasons = severityHit ? ['severity'] : [];
        } else {
            const findings = this.getAllVulnerabilityDetails(scan.id);
            const evalSet = honorSuppressions
                ? applySuppressions(findings, scan.image_ref, this.getCveSuppressions()).filter((f) => !f.suppressed)
                : findings;
            const intel = inputs.blockOnKev ? this.getCveIntel(evalSet.map((f) => f.vulnerability_id)) : null;
            reasons = evaluatePolicyRisk(evalSet, (cveId) => intel?.get(cveId)?.kev === true, inputs).reasons;
        }
        return {
            policyId: policy.id,
            policyName: policy.name,
            maxSeverity: policy.max_severity,
            reasons,
            violated: reasons.length > 0,
            evaluatedAt: Date.now(),
        };
    }

    /**
     * Persist a PolicyEvaluation onto a scan row. Pass null to clear.
     * Encoded as JSON so consumers round-trip through parsePolicyEvaluation().
     */
    public setScanPolicyEvaluation(
        scanId: number,
        evaluation: PolicyEvaluation | null,
    ): void {
        const json = evaluation ? JSON.stringify(evaluation) : null;
        this.db
            .prepare('UPDATE vulnerability_scans SET policy_evaluation = ? WHERE id = ?')
            .run(json, scanId);
    }

    // --- Fleet Sync Status ---

    public getFleetSyncStatuses(): FleetSyncStatus[] {
        return this.db
            .prepare('SELECT * FROM fleet_sync_status ORDER BY node_id, resource')
            .all() as FleetSyncStatus[];
    }

    public recordFleetSyncSuccess(nodeId: number, resource: string): void {
        const now = Date.now();
        this.db
            .prepare(
                `INSERT INTO fleet_sync_status (node_id, resource, last_success_at, last_failure_at, last_error,
                                                 sticky_error_code, sticky_error_expected, sticky_error_got)
                 VALUES (?, ?, ?, NULL, NULL, NULL, NULL, NULL)
                 ON CONFLICT(node_id, resource) DO UPDATE SET
                   last_success_at = excluded.last_success_at,
                   last_error = NULL,
                   sticky_error_code = NULL,
                   sticky_error_expected = NULL,
                   sticky_error_got = NULL`,
            )
            .run(nodeId, resource, now);
    }

    public recordFleetSyncFailure(nodeId: number, resource: string, error: string): void {
        const now = Date.now();
        this.db
            .prepare(
                `INSERT INTO fleet_sync_status (node_id, resource, last_failure_at, last_error)
                 VALUES (?, ?, ?, ?)
                 ON CONFLICT(node_id, resource) DO UPDATE SET
                   last_failure_at = excluded.last_failure_at,
                   last_error = excluded.last_error`,
            )
            .run(nodeId, resource, now, error);
    }

    /**
     * Mark a (node, resource) pair as having hit a non-retriable failure. The
     * retry service skips sticky rows and the push paths short-circuit before
     * any HTTP call. The first such failure still records `last_failure_at` +
     * `last_error` via `recordFleetSyncFailure`; the sticky write is additive.
     *
     * `expected` and `got` carry the fingerprints from a 409
     * CONTROL_IDENTITY_MISMATCH response so the UI can render
     * "anchored to <expected>, this central is <got>" without parsing the
     * error string.
     */
    public setFleetSyncSticky(
        nodeId: number,
        resource: string,
        code: string,
        expected: string | null,
        got: string | null,
    ): void {
        this.db
            .prepare(
                `INSERT INTO fleet_sync_status (node_id, resource, sticky_error_code,
                                                 sticky_error_expected, sticky_error_got)
                 VALUES (?, ?, ?, ?, ?)
                 ON CONFLICT(node_id, resource) DO UPDATE SET
                   sticky_error_code = excluded.sticky_error_code,
                   sticky_error_expected = excluded.sticky_error_expected,
                   sticky_error_got = excluded.sticky_error_got`,
            )
            .run(nodeId, resource, code, expected, got);
    }

    public getFleetSyncStickyCode(nodeId: number, resource: string): string | null {
        const row = this.db
            .prepare(
                `SELECT sticky_error_code FROM fleet_sync_status
                 WHERE node_id = ? AND resource = ?`,
            )
            .get(nodeId, resource) as { sticky_error_code: string | null } | undefined;
        return row?.sticky_error_code ?? null;
    }

    /**
     * Clear every sticky-error row for one node id. Used by the
     * reset-anchor endpoint after the peer has acknowledged a reanchor; the
     * next push attempt re-tries normally.
     */
    public clearFleetSyncStickyForNode(nodeId: number): void {
        this.db
            .prepare(
                `UPDATE fleet_sync_status
                 SET sticky_error_code = NULL,
                     sticky_error_expected = NULL,
                     sticky_error_got = NULL
                 WHERE node_id = ?`,
            )
            .run(nodeId);
    }

    public getFailedSyncTargets(resource: string, maxAgeMs: number): FleetSyncStatus[] {
        const cutoff = Date.now() - maxAgeMs;
        return this.db
            .prepare(
                `SELECT * FROM fleet_sync_status
                 WHERE resource = ?
                   AND (last_failure_at IS NOT NULL AND last_failure_at > ?)
                   AND (last_success_at IS NULL OR last_success_at < last_failure_at)
                   AND sticky_error_code IS NULL`,
            )
            .all(resource, cutoff) as FleetSyncStatus[];
    }

    // --- CVE Suppressions ---

    public getCveSuppressions(): CveSuppression[] {
        return this.db
            .prepare('SELECT * FROM cve_suppressions ORDER BY cve_id, pkg_name')
            .all() as CveSuppression[];
    }

    /** Local-only CVE suppressions; mirrors `getLocalScanPolicies`. */
    public getLocalCveSuppressions(): CveSuppression[] {
        return this.db
            .prepare('SELECT * FROM cve_suppressions WHERE replicated_from_control = 0 ORDER BY cve_id, pkg_name')
            .all() as CveSuppression[];
    }

    public getCveSuppression(id: number): CveSuppression | null {
        return (
            (this.db.prepare('SELECT * FROM cve_suppressions WHERE id = ?')
                .get(id) as CveSuppression | undefined) ?? null
        );
    }

    public createCveSuppression(
        suppression: Omit<CveSuppression, 'id'>,
    ): CveSuppression {
        const result = this.db
            .prepare(
                `INSERT INTO cve_suppressions
                    (cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control, status, justification)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
            )
            .run(
                suppression.cve_id,
                suppression.pkg_name,
                suppression.image_pattern,
                suppression.reason,
                suppression.created_by,
                suppression.created_at,
                suppression.expires_at,
                suppression.replicated_from_control ?? 0,
                suppression.status ?? 'accepted',
                suppression.justification ?? null,
            );
        return {
            ...suppression,
            status: suppression.status ?? 'accepted',
            justification: suppression.justification ?? null,
            id: result.lastInsertRowid as number,
        };
    }

    public updateCveSuppression(
        id: number,
        updates: Partial<Pick<CveSuppression, 'reason' | 'image_pattern' | 'expires_at' | 'status' | 'justification'>>,
    ): CveSuppression | null {
        const existing = this.getCveSuppression(id);
        if (!existing) return null;
        const ALLOWED = new Set(['reason', 'image_pattern', 'expires_at', 'status', 'justification']);
        const fields: string[] = [];
        const values: unknown[] = [];
        for (const [key, value] of Object.entries(updates)) {
            if (!ALLOWED.has(key)) continue;
            fields.push(`${key} = ?`);
            values.push(value);
        }
        if (fields.length === 0) return existing;
        values.push(id);
        this.db
            .prepare(`UPDATE cve_suppressions SET ${fields.join(', ')} WHERE id = ?`)
            .run(...(values as never[]));
        return this.getCveSuppression(id);
    }

    public deleteCveSuppression(id: number): void {
        this.db.prepare('DELETE FROM cve_suppressions WHERE id = ?').run(id);
    }

    /**
     * Replace all replicated CVE suppressions in a single transaction.
     * Preserves rows flagged as locally created on this instance.
     */
    public replaceReplicatedCveSuppressions(rows: Array<Omit<CveSuppression, 'id'>>): void {
        const deleteStmt = this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1');
        const insertStmt = this.db.prepare(
            `INSERT INTO cve_suppressions
                (cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control, status, justification)
             VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
        );
        const txn = this.db.transaction((items: Array<Omit<CveSuppression, 'id'>>) => {
            deleteStmt.run();
            for (const s of items) {
                insertStmt.run(
                    s.cve_id,
                    s.pkg_name,
                    s.image_pattern,
                    s.reason,
                    s.created_by,
                    s.created_at,
                    s.expires_at,
                    // Back-compat: a control on an older version omits these in its push.
                    s.status ?? 'accepted',
                    s.justification ?? null,
                );
            }
        });
        txn(rows);
    }

    // --- Misconfig Acknowledgements ---

    public getMisconfigAcknowledgements(): MisconfigAcknowledgement[] {
        return this.db
            .prepare('SELECT * FROM misconfig_acknowledgements ORDER BY rule_id, stack_pattern')
            .all() as MisconfigAcknowledgement[];
    }

    /** Local-only acknowledgements; mirrors `getLocalCveSuppressions`. */
    public getLocalMisconfigAcknowledgements(): MisconfigAcknowledgement[] {
        return this.db
            .prepare('SELECT * FROM misconfig_acknowledgements WHERE replicated_from_control = 0 ORDER BY rule_id, stack_pattern')
            .all() as MisconfigAcknowledgement[];
    }

    public getMisconfigAcknowledgement(id: number): MisconfigAcknowledgement | null {
        return (
            (this.db.prepare('SELECT * FROM misconfig_acknowledgements WHERE id = ?')
                .get(id) as MisconfigAcknowledgement | undefined) ?? null
        );
    }

    public createMisconfigAcknowledgement(
        ack: Omit<MisconfigAcknowledgement, 'id'>,
    ): MisconfigAcknowledgement {
        const result = this.db
            .prepare(
                `INSERT INTO misconfig_acknowledgements
                    (rule_id, stack_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
                 VALUES (?, ?, ?, ?, ?, ?, ?)`,
            )
            .run(
                ack.rule_id,
                ack.stack_pattern,
                ack.reason,
                ack.created_by,
                ack.created_at,
                ack.expires_at,
                ack.replicated_from_control ?? 0,
            );
        return { ...ack, id: result.lastInsertRowid as number };
    }

    public updateMisconfigAcknowledgement(
        id: number,
        updates: Partial<Pick<MisconfigAcknowledgement, 'reason' | 'stack_pattern' | 'expires_at'>>,
    ): MisconfigAcknowledgement | null {
        const existing = this.getMisconfigAcknowledgement(id);
        if (!existing) return null;
        const ALLOWED = new Set(['reason', 'stack_pattern', 'expires_at']);
        const fields: string[] = [];
        const values: unknown[] = [];
        for (const [key, value] of Object.entries(updates)) {
            if (!ALLOWED.has(key)) continue;
            fields.push(`${key} = ?`);
            values.push(value);
        }
        if (fields.length === 0) return existing;
        values.push(id);
        this.db
            .prepare(`UPDATE misconfig_acknowledgements SET ${fields.join(', ')} WHERE id = ?`)
            .run(...(values as never[]));
        return this.getMisconfigAcknowledgement(id);
    }

    public deleteMisconfigAcknowledgement(id: number): void {
        this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE id = ?').run(id);
    }

    /**
     * Replace all replicated misconfig acknowledgements in a single transaction.
     * Preserves rows flagged as locally created on this instance.
     */
    public replaceReplicatedMisconfigAcknowledgements(
        rows: Array<Omit<MisconfigAcknowledgement, 'id'>>,
    ): void {
        const deleteStmt = this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE replicated_from_control = 1');
        const insertStmt = this.db.prepare(
            `INSERT INTO misconfig_acknowledgements
                (rule_id, stack_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
             VALUES (?, ?, ?, ?, ?, ?, 1)`,
        );
        const txn = this.db.transaction((items: Array<Omit<MisconfigAcknowledgement, 'id'>>) => {
            deleteStmt.run();
            for (const a of items) {
                insertStmt.run(
                    a.rule_id,
                    a.stack_pattern,
                    a.reason,
                    a.created_by,
                    a.created_at,
                    a.expires_at,
                );
            }
        });
        txn(rows);
    }

    /**
     * Null out `vulnerability_scans.policy_evaluation` rows whose `$.policyId`
     * no longer exists in `scan_policies`. Used after replicated rows are
     * wiped (sync replace, demote) so cached scan banners do not reference
     * deleted policies.
     */
    public clearOrphanPolicyEvaluations(): void {
        this.db
            .prepare(
                `UPDATE vulnerability_scans
                    SET policy_evaluation = NULL
                  WHERE policy_evaluation IS NOT NULL
                    AND CAST(json_extract(policy_evaluation, '$.policyId') AS INTEGER)
                        NOT IN (SELECT id FROM scan_policies)`,
            )
            .run();
    }

    /**
     * Atomically delete every replicated_from_control row from scan_policies,
     * cve_suppressions, and misconfig_acknowledgements, then null out any
     * orphaned policy_evaluation cache. Used by the demote endpoint and any
     * future "drop replicated state" operation.
     */
    public clearReplicatedRows(): void {
        this.transaction(() => {
            this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1').run();
            this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1').run();
            this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE replicated_from_control = 1').run();
            this.clearOrphanPolicyEvaluations();
        });
    }

    // --- Stack Labels ---

    public getLabels(nodeId: number): Label[] {
        return this.db.prepare('SELECT * FROM stack_labels WHERE node_id = ? ORDER BY name').all(nodeId) as Label[];
    }

    public getLabel(id: number, nodeId: number): Label | null {
        return (this.db.prepare('SELECT * FROM stack_labels WHERE id = ? AND node_id = ?')
            .get(id, nodeId) as Label) ?? null;
    }

    public getLabelCount(nodeId: number): number {
        const row = this.db.prepare('SELECT COUNT(*) as cnt FROM stack_labels WHERE node_id = ?')
            .get(nodeId) as { cnt: number };
        return row.cnt;
    }

    public createLabel(nodeId: number, name: string, color: string): Label {
        const result = this.db.prepare(
            'INSERT INTO stack_labels (node_id, name, color) VALUES (?, ?, ?)'
        ).run(nodeId, name, color);
        return { id: result.lastInsertRowid as number, node_id: nodeId, name, color };
    }

    public updateLabel(id: number, nodeId: number, updates: { name?: string; color?: string }): Label | null {
        const label = this.db.prepare('SELECT * FROM stack_labels WHERE id = ? AND node_id = ?').get(id, nodeId) as Label | undefined;
        if (!label) return null;
        const name = updates.name ?? label.name;
        const color = updates.color ?? label.color;
        this.db.prepare('UPDATE stack_labels SET name = ?, color = ? WHERE id = ? AND node_id = ?').run(name, color, id, nodeId);
        return { ...label, name, color };
    }

    public deleteLabel(id: number, nodeId: number): void {
        this.db.prepare('DELETE FROM stack_labels WHERE id = ? AND node_id = ?').run(id, nodeId);
    }

    public setStackLabels(stackName: string, nodeId: number, labelIds: number[]): void {
        const txn = this.db.transaction(() => {
            if (labelIds.length > 0) {
                const placeholders = labelIds.map(() => '?').join(',');
                const validCount = this.db.prepare(
                    `SELECT COUNT(*) as cnt FROM stack_labels WHERE id IN (${placeholders}) AND node_id = ?`
                ).get(...labelIds, nodeId) as { cnt: number };
                if (validCount.cnt !== labelIds.length) {
                    throw new Error('One or more label IDs are invalid for this node');
                }
            }
            this.db.prepare('DELETE FROM stack_label_assignments WHERE stack_name = ? AND node_id = ?').run(stackName, nodeId);
            const insert = this.db.prepare('INSERT INTO stack_label_assignments (label_id, stack_name, node_id) VALUES (?, ?, ?)');
            for (const labelId of labelIds) {
                insert.run(labelId, stackName, nodeId);
            }
        });
        txn();
    }

    /**
     * Add labels to a stack without disturbing its existing assignments. Unlike
     * `setStackLabels` (replace), this is additive: validation and
     * `INSERT OR IGNORE` run in one transaction and the
     * `(label_id, stack_name, node_id)` primary key makes re-adds idempotent, so
     * two additive writes to the same stack never drop each other's labels. A
     * concurrent `setStackLabels` (replace) still wins last-writer, which is the
     * intended replace semantics, not a lost update.
     */
    public addStackLabels(stackName: string, nodeId: number, labelIds: number[]): void {
        if (labelIds.length === 0) return;
        const txn = this.db.transaction(() => {
            const placeholders = labelIds.map(() => '?').join(',');
            const validCount = this.db.prepare(
                `SELECT COUNT(*) as cnt FROM stack_labels WHERE id IN (${placeholders}) AND node_id = ?`
            ).get(...labelIds, nodeId) as { cnt: number };
            if (validCount.cnt !== labelIds.length) {
                throw new Error('One or more label IDs are invalid for this node');
            }
            const insert = this.db.prepare('INSERT OR IGNORE INTO stack_label_assignments (label_id, stack_name, node_id) VALUES (?, ?, ?)');
            for (const labelId of labelIds) {
                insert.run(labelId, stackName, nodeId);
            }
        });
        txn();
    }

    public getLabelsForStacks(nodeId: number): Record<string, Label[]> {
        const rows = this.db.prepare(`
            SELECT a.stack_name, l.id, l.node_id, l.name, l.color
            FROM stack_label_assignments a
            JOIN stack_labels l ON a.label_id = l.id
            WHERE a.node_id = ?
            ORDER BY l.name
        `).all(nodeId) as (Label & { stack_name: string })[];
        const result: Record<string, Label[]> = {};
        for (const row of rows) {
            if (!result[row.stack_name]) result[row.stack_name] = [];
            result[row.stack_name].push({ id: row.id, node_id: row.node_id, name: row.name, color: row.color });
        }
        return result;
    }

    public getStacksForLabel(labelId: number, nodeId: number): string[] {
        const rows = this.db.prepare('SELECT stack_name FROM stack_label_assignments WHERE label_id = ? AND node_id = ?')
            .all(labelId, nodeId) as { stack_name: string }[];
        return rows.map(r => r.stack_name);
    }

    public cleanupStaleAssignments(nodeId: number, validStackNames: string[]): number {
        if (validStackNames.length === 0) {
            const result = this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(nodeId);
            return result.changes;
        }
        const placeholders = validStackNames.map(() => '?').join(',');
        const result = this.db.prepare(
            `DELETE FROM stack_label_assignments WHERE node_id = ? AND stack_name NOT IN (${placeholders})`
        ).run(nodeId, ...validStackNames);
        return result.changes;
    }

    // --- Node Labels (fleet-level orchestration) ---

    public listNodeLabels(nodeId?: number): NodeLabelRow[] {
        const sql = nodeId !== undefined
            ? 'SELECT id, node_id, label, created_at FROM node_labels WHERE node_id = ? ORDER BY label'
            : 'SELECT id, node_id, label, created_at FROM node_labels ORDER BY node_id, label';
        const rows = nodeId !== undefined
            ? this.db.prepare(sql).all(nodeId)
            : this.db.prepare(sql).all();
        return rows as NodeLabelRow[];
    }

    public listDistinctNodeLabels(): string[] {
        const rows = this.db.prepare('SELECT DISTINCT label FROM node_labels ORDER BY label').all() as { label: string }[];
        return rows.map(r => r.label);
    }

    public addNodeLabel(nodeId: number, label: string): NodeLabelRow {
        const trimmed = label.trim();
        if (!trimmed) throw new Error('Label must not be empty');
        const now = Date.now();
        const result = this.db.prepare(
            'INSERT OR IGNORE INTO node_labels (node_id, label, created_at) VALUES (?, ?, ?)'
        ).run(nodeId, trimmed, now);
        const row = result.changes > 0
            ? this.db.prepare('SELECT id, node_id, label, created_at FROM node_labels WHERE id = ?').get(result.lastInsertRowid)
            : this.db.prepare('SELECT id, node_id, label, created_at FROM node_labels WHERE node_id = ? AND label = ?').get(nodeId, trimmed);
        return row as NodeLabelRow;
    }

    public removeNodeLabel(nodeId: number, label: string): boolean {
        const result = this.db.prepare('DELETE FROM node_labels WHERE node_id = ? AND label = ?').run(nodeId, label);
        return result.changes > 0;
    }

    public getNodeLabelsMap(): Record<number, string[]> {
        const rows = this.db.prepare('SELECT node_id, label FROM node_labels ORDER BY node_id, label').all() as { node_id: number; label: string }[];
        const map: Record<number, string[]> = {};
        for (const row of rows) {
            if (!map[row.node_id]) map[row.node_id] = [];
            map[row.node_id].push(row.label);
        }
        return map;
    }

    // --- Blueprints ---

    private parseBlueprint(row: Record<string, unknown>): Blueprint {
        return {
            id: row.id as number,
            name: row.name as string,
            description: (row.description as string | null) ?? null,
            compose_content: row.compose_content as string,
            selector: JSON.parse(row.selector_json as string) as BlueprintSelector,
            drift_mode: row.drift_mode as DriftMode,
            classification: row.classification as BlueprintClassification,
            classification_reasons: row.classification_reasons
                ? (JSON.parse(row.classification_reasons as string) as string[])
                : [],
            enabled: row.enabled === 1,
            revision: row.revision as number,
            created_at: row.created_at as number,
            updated_at: row.updated_at as number,
            created_by: (row.created_by as string | null) ?? null,
            pinned_node_id: (row.pinned_node_id as number | null) ?? null,
            approval_status: (row.approval_status as 'pending' | 'approved' | undefined) === 'approved' ? 'approved' : 'pending',
            approved_intent_fingerprint: (row.approved_intent_fingerprint as string | null) ?? null,
            approved_blast_json: (row.approved_blast_json as string | null) ?? null,
            approved_at: (row.approved_at as number | null) ?? null,
            approved_by: (row.approved_by as string | null) ?? null,
        };
    }

    public listBlueprints(): Blueprint[] {
        return this.db.prepare('SELECT * FROM blueprints ORDER BY name')
            .all()
            .map(row => this.parseBlueprint(row as Record<string, unknown>));
    }

    public listEnabledBlueprints(): Blueprint[] {
        return this.db.prepare('SELECT * FROM blueprints WHERE enabled = 1 ORDER BY name')
            .all()
            .map(row => this.parseBlueprint(row as Record<string, unknown>));
    }

    public getBlueprint(id: number): Blueprint | undefined {
        const row = this.db.prepare('SELECT * FROM blueprints WHERE id = ?').get(id) as Record<string, unknown> | undefined;
        return row ? this.parseBlueprint(row) : undefined;
    }

    public getBlueprintByName(name: string): Blueprint | undefined {
        const row = this.db.prepare('SELECT * FROM blueprints WHERE name = ?').get(name) as Record<string, unknown> | undefined;
        return row ? this.parseBlueprint(row) : undefined;
    }

    public createBlueprint(input: {
        name: string;
        description: string | null;
        compose_content: string;
        selector: BlueprintSelector;
        drift_mode: DriftMode;
        classification: BlueprintClassification;
        classification_reasons: string[];
        enabled: boolean;
        created_by: string | null;
    }): Blueprint {
        const now = Date.now();
        const result = this.db.prepare(
            `INSERT INTO blueprints (name, description, compose_content, selector_json, drift_mode, classification, classification_reasons, enabled, revision, created_at, updated_at, created_by)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`
        ).run(
            input.name,
            input.description,
            input.compose_content,
            JSON.stringify(input.selector),
            input.drift_mode,
            input.classification,
            JSON.stringify(input.classification_reasons),
            input.enabled ? 1 : 0,
            now,
            now,
            input.created_by,
        );
        const created = this.getBlueprint(result.lastInsertRowid as number);
        if (!created) throw new Error('Failed to fetch created blueprint');
        return created;
    }

    public updateBlueprint(id: number, updates: {
        name?: string;
        description?: string | null;
        compose_content?: string;
        selector?: BlueprintSelector;
        drift_mode?: DriftMode;
        classification?: BlueprintClassification;
        classification_reasons?: string[];
        enabled?: boolean;
        bumpRevision?: boolean;
    }): Blueprint | undefined {
        const existing = this.getBlueprint(id);
        if (!existing) return undefined;
        const fields: string[] = [];
        const values: unknown[] = [];
        if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
        if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); }
        if (updates.compose_content !== undefined) { fields.push('compose_content = ?'); values.push(updates.compose_content); }
        if (updates.selector !== undefined) { fields.push('selector_json = ?'); values.push(JSON.stringify(updates.selector)); }
        if (updates.drift_mode !== undefined) { fields.push('drift_mode = ?'); values.push(updates.drift_mode); }
        if (updates.classification !== undefined) { fields.push('classification = ?'); values.push(updates.classification); }
        if (updates.classification_reasons !== undefined) { fields.push('classification_reasons = ?'); values.push(JSON.stringify(updates.classification_reasons)); }
        if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
        if (updates.bumpRevision) { fields.push('revision = revision + 1'); }
        const invalidatesApproval = updates.name !== undefined
            || updates.compose_content !== undefined
            || updates.selector !== undefined
            || updates.drift_mode !== undefined
            || updates.enabled !== undefined;
        if (invalidatesApproval) {
            fields.push("approval_status = 'pending'");
            fields.push('approved_intent_fingerprint = NULL');
            fields.push('approved_blast_json = NULL');
            fields.push('approved_at = NULL');
            fields.push('approved_by = NULL');
        }
        fields.push('updated_at = ?');
        values.push(Date.now());
        values.push(id);
        this.db.prepare(`UPDATE blueprints SET ${fields.join(', ')} WHERE id = ?`).run(...values);
        return this.getBlueprint(id);
    }

    public deleteBlueprint(id: number): boolean {
        const result = this.db.prepare('DELETE FROM blueprints WHERE id = ?').run(id);
        return result.changes > 0;
    }

    // --- Blueprint Deployments ---

    private parseBlueprintDeployment(row: Record<string, unknown>): BlueprintDeployment {
        return {
            id: row.id as number,
            blueprint_id: row.blueprint_id as number,
            node_id: row.node_id as number,
            status: row.status as BlueprintDeploymentStatus,
            applied_revision: (row.applied_revision as number | null) ?? null,
            last_deployed_at: (row.last_deployed_at as number | null) ?? null,
            last_checked_at: (row.last_checked_at as number | null) ?? null,
            last_drift_at: (row.last_drift_at as number | null) ?? null,
            drift_summary: (row.drift_summary as string | null) ?? null,
            last_error: (row.last_error as string | null) ?? null,
        };
    }

    public listDeployments(blueprintId: number): BlueprintDeployment[] {
        return this.db.prepare('SELECT * FROM blueprint_deployments WHERE blueprint_id = ? ORDER BY node_id')
            .all(blueprintId)
            .map(row => this.parseBlueprintDeployment(row as Record<string, unknown>));
    }

    public listAllDeployments(): BlueprintDeployment[] {
        return this.db.prepare('SELECT * FROM blueprint_deployments')
            .all()
            .map(row => this.parseBlueprintDeployment(row as Record<string, unknown>));
    }

    public getDeployment(blueprintId: number, nodeId: number): BlueprintDeployment | undefined {
        const row = this.db.prepare('SELECT * FROM blueprint_deployments WHERE blueprint_id = ? AND node_id = ?').get(blueprintId, nodeId) as Record<string, unknown> | undefined;
        return row ? this.parseBlueprintDeployment(row) : undefined;
    }

    public upsertDeployment(input: {
        blueprint_id: number;
        node_id: number;
        status: BlueprintDeploymentStatus;
        applied_revision?: number | null;
        last_deployed_at?: number | null;
        last_checked_at?: number | null;
        last_drift_at?: number | null;
        drift_summary?: string | null;
        last_error?: string | null;
    }): BlueprintDeployment {
        const existing = this.getDeployment(input.blueprint_id, input.node_id);
        if (existing) {
            const fields: string[] = ['status = ?'];
            const values: unknown[] = [input.status];
            if (input.applied_revision !== undefined) { fields.push('applied_revision = ?'); values.push(input.applied_revision); }
            if (input.last_deployed_at !== undefined) { fields.push('last_deployed_at = ?'); values.push(input.last_deployed_at); }
            if (input.last_checked_at !== undefined) { fields.push('last_checked_at = ?'); values.push(input.last_checked_at); }
            if (input.last_drift_at !== undefined) { fields.push('last_drift_at = ?'); values.push(input.last_drift_at); }
            if (input.drift_summary !== undefined) { fields.push('drift_summary = ?'); values.push(input.drift_summary); }
            if (input.last_error !== undefined) { fields.push('last_error = ?'); values.push(input.last_error); }
            values.push(input.blueprint_id, input.node_id);
            this.db.prepare(`UPDATE blueprint_deployments SET ${fields.join(', ')} WHERE blueprint_id = ? AND node_id = ?`).run(...values);
        } else {
            this.db.prepare(
                `INSERT INTO blueprint_deployments (blueprint_id, node_id, status, applied_revision, last_deployed_at, last_checked_at, last_drift_at, drift_summary, last_error)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
            ).run(
                input.blueprint_id,
                input.node_id,
                input.status,
                input.applied_revision ?? null,
                input.last_deployed_at ?? null,
                input.last_checked_at ?? null,
                input.last_drift_at ?? null,
                input.drift_summary ?? null,
                input.last_error ?? null,
            );
        }
        const updated = this.getDeployment(input.blueprint_id, input.node_id);
        if (!updated) throw new Error('Failed to upsert deployment');
        return updated;
    }

    public deleteDeployment(blueprintId: number, nodeId: number): void {
        this.db.prepare('DELETE FROM blueprint_deployments WHERE blueprint_id = ? AND node_id = ?').run(blueprintId, nodeId);
    }

    // --- Secrets ---

    public listSecrets(): SecretRow[] {
        return this.db.prepare(
            'SELECT id, name, description, current_version, created_at, created_by, updated_at FROM secrets ORDER BY name ASC'
        ).all() as SecretRow[];
    }

    public getSecret(id: number): SecretRow | undefined {
        return this.db.prepare(
            'SELECT id, name, description, current_version, created_at, created_by, updated_at FROM secrets WHERE id = ?'
        ).get(id) as SecretRow | undefined;
    }

    public listSecretVersions(secretId: number): SecretVersionRow[] {
        return this.db.prepare(
            'SELECT id, secret_id, version, encrypted_payload, key_count, created_at, created_by, note FROM secret_versions WHERE secret_id = ? ORDER BY version DESC'
        ).all(secretId) as SecretVersionRow[];
    }

    public getCurrentSecretVersion(secretId: number): SecretVersionRow | undefined {
        return this.db.prepare(
            `SELECT v.id, v.secret_id, v.version, v.encrypted_payload, v.key_count, v.created_at, v.created_by, v.note
             FROM secret_versions v
             INNER JOIN secrets s ON s.id = v.secret_id AND s.current_version = v.version
             WHERE v.secret_id = ?`
        ).get(secretId) as SecretVersionRow | undefined;
    }

    public createSecretWithVersion(input: {
        name: string;
        description: string;
        encryptedPayload: string;
        keyCount: number;
        createdBy: string;
        note: string;
    }): { id: number; version: number } {
        const now = Date.now();
        const txn = this.db.transaction(() => {
            const insertSecret = this.db.prepare(
                'INSERT INTO secrets (name, description, current_version, created_at, created_by, updated_at) VALUES (?, ?, 1, ?, ?, ?)'
            );
            const result = insertSecret.run(input.name, input.description, now, input.createdBy, now);
            const secretId = Number(result.lastInsertRowid);
            this.db.prepare(
                'INSERT INTO secret_versions (secret_id, version, encrypted_payload, key_count, created_at, created_by, note) VALUES (?, 1, ?, ?, ?, ?, ?)'
            ).run(secretId, input.encryptedPayload, input.keyCount, now, input.createdBy, input.note);
            return { id: secretId, version: 1 };
        });
        return txn();
    }

    public updateSecretWithVersion(input: {
        secretId: number;
        description: string | null;
        encryptedPayload: string;
        keyCount: number;
        createdBy: string;
        note: string;
    }): { version: number } {
        const now = Date.now();
        const txn = this.db.transaction(() => {
            const current = this.db.prepare('SELECT current_version FROM secrets WHERE id = ?').get(input.secretId) as { current_version: number } | undefined;
            if (!current) throw new Error('Secret not found');
            const nextVersion = current.current_version + 1;
            this.db.prepare(
                'INSERT INTO secret_versions (secret_id, version, encrypted_payload, key_count, created_at, created_by, note) VALUES (?, ?, ?, ?, ?, ?, ?)'
            ).run(input.secretId, nextVersion, input.encryptedPayload, input.keyCount, now, input.createdBy, input.note);
            if (input.description === null) {
                this.db.prepare('UPDATE secrets SET current_version = ?, updated_at = ? WHERE id = ?').run(nextVersion, now, input.secretId);
            } else {
                this.db.prepare('UPDATE secrets SET current_version = ?, description = ?, updated_at = ? WHERE id = ?').run(nextVersion, input.description, now, input.secretId);
            }
            return { version: nextVersion };
        });
        return txn();
    }

    public deleteSecret(id: number): boolean {
        const result = this.db.prepare('DELETE FROM secrets WHERE id = ?').run(id);
        return result.changes > 0;
    }

    public insertSecretPushes(rows: Array<Omit<SecretPushRow, 'id'>>): void {
        if (rows.length === 0) return;
        const stmt = this.db.prepare(
            `INSERT INTO secret_pushes (secret_id, version, push_id, node_id, stack_name, env_file_basename, status, error, added_count, changed_count, unchanged_count, pushed_by, pushed_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        );
        const txn = this.db.transaction((entries: Array<Omit<SecretPushRow, 'id'>>) => {
            for (const r of entries) {
                stmt.run(
                    r.secret_id, r.version, r.push_id, r.node_id, r.stack_name, r.env_file_basename,
                    r.status, r.error, r.added_count, r.changed_count, r.unchanged_count, r.pushed_by, r.pushed_at,
                );
            }
        });
        txn(rows);
    }

    public listSecretPushes(secretId: number, limit = 50): SecretPushRow[] {
        return this.db.prepare(
            `SELECT id, secret_id, version, push_id, node_id, stack_name, env_file_basename, status, error,
                    added_count, changed_count, unchanged_count, pushed_by, pushed_at
             FROM secret_pushes
             WHERE secret_id = ?
             ORDER BY pushed_at DESC
             LIMIT ?`
        ).all(secretId, limit) as SecretPushRow[];
    }
}
