self::MIGRATION_VERSION, 'ready' => false, 'missing_tables' => ['database'], 'missing_columns' => [], 'required_indexes' => $requiredIndexes, 'missing_indexes' => $requiredIndexes, 'preflight_conflicts' => ['database_unavailable'], ]; } foreach ([ 'xlvask_usage_logs', 'xlvask_automation_suggestions', 'xlvask_automation_feedback', 'xlvask_automation_openai_cache', 'xlvask_autopilot_runs', 'xlvask_autopilot_run_items', 'xlvask_automation_audit', 'xlvask_automation_calibrations', 'xlvask_automation_calibration_label_events', 'xlvask_automation_decision_previews', 'xlvask_automation_policy_state', 'xlvask_automation_policy_previews', 'xlvask_automation_policy_events', 'xlvask_automation_action_events', ] as $table) { if (!self::tableExists($db, $table)) { $missingTables[] = $table; } } $requiredColumns = [ 'xlvask_usage_logs' => [ 'ignored_at', 'ignored_by', 'ignored_reason', 'cached_total_net_amount', 'cached_primary_product_name', 'cached_amount_at', 'source_hash', 'source_revision', 'source_observed_at', 'source_stable_since', 'source_observation_count', 'import_state', 'resolution_state', 'certainty', 'planned_action', 'state_reason', 'expected_version', 'last_run_id', 'last_evaluated_at', ], 'xlvask_automation_suggestions' => [ 'run_id', 'policy_version', 'planner_identity_hash', 'model', 'model_confidence', 'calibrated_probability', 'certainty', 'evidence_json', 'contradictions_json', 'risk_flags_json', 'plan_steps_json', 'expected_version', 'input_hash', ], 'xlvask_autopilot_runs' => [ 'idempotency_key', 'mode', 'status', 'phase', 'date_from', 'date_to', 'force_refetch', 'requested_ids_json', 'requested_limit', 'request_hash', 'scope_hall_ids_json', 'processed', 'total', 'summary_json', 'warning', 'error', 'lease_token', 'lease_expires_at', 'attempt_count', 'max_attempts', 'next_attempt_at', 'created_by', 'created_at', 'updated_at', 'started_at', 'finished_at', 'active_execute_slot', ], 'xlvask_autopilot_run_items' => [ 'run_id', 'usage_log_id', 'wash_id', 'import_state', 'resolution_state', 'certainty', 'planned_action', 'source_hash', 'expected_version', 'result_json', 'error', 'created_at', 'updated_at', ], 'xlvask_automation_calibrations' => [ 'policy_version', 'segment_key', 'automation_identity_hash', 'precision_value', 'wilson_lower_bound', 'holdout_examples', 'segment_examples', 'contradictions', 'calibrated_probability', 'artifact_hash', 'active', 'backtest_json', 'created_by', 'activated_by', 'activated_at', 'invalidated_at', 'created_at', ], 'xlvask_automation_calibration_label_events' => [ 'suggestion_id', 'outcome', 'adjudication_outcome', 'adjudicated_by', 'adjudicated_at', 'legacy_label_id', ], 'xlvask_automation_policy_state' => [ 'policy_version', 'planner_identity_hash', 'stage', 'halted', 'attach_enabled', 'create_enabled', 'halt_reason', 'attach_halt_reason', 'create_halt_reason', 'halted_at', 'halted_by', 'attach_activated_at', 'attach_activated_by', 'create_activated_at', 'create_activated_by', 'expected_version', 'created_at', 'updated_at', ], 'xlvask_automation_policy_previews' => [ 'selection_hash', 'requested_transition', 'payload_json', 'created_by', 'expires_at', 'applied_at', 'created_at', ], 'xlvask_automation_policy_events' => ['event_type', 'actor_id', 'details_json', 'created_at'], 'xlvask_automation_action_events' => [ 'suggestion_id', 'run_id', 'hall_id', 'action', 'source', 'policy_version', 'planner_identity_hash', 'review_outcome', 'reviewed_by', 'reviewed_at', 'created_at', ], ]; foreach ($requiredColumns as $table => $columns) { if (!self::tableExists($db, $table)) { continue; } foreach ($columns as $column) { if (!self::columnExists($db, $table, $column)) { $missingColumns[] = $table . '.' . $column; } } } foreach ($requiredIndexes as $requiredIndex) { [$table, $index] = explode('.', $requiredIndex, 2); if (!self::indexExists($db, $table, $index)) { $missingIndexes[] = $requiredIndex; } } $conflicts = self::activeExecuteRunConflicts($db); return [ 'version' => self::MIGRATION_VERSION, 'ready' => $missingTables === [] && $missingColumns === [] && $missingIndexes === [] && $conflicts === [], 'missing_tables' => $missingTables, 'missing_columns' => $missingColumns, 'required_indexes' => $requiredIndexes, 'missing_indexes' => $missingIndexes, 'preflight_conflicts' => $conflicts, ]; } private static function ensureAutomationTables(object $db): void { $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` ( `id` INT NOT NULL AUTO_INCREMENT, `usage_log_id` INT NOT NULL, `wash_id` VARCHAR(128) NOT NULL, `signature_hash` CHAR(64) NOT NULL, `signature_json` LONGTEXT NULL, `action` VARCHAR(32) NOT NULL, `status` VARCHAR(32) NOT NULL DEFAULT 'suggested', `confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000, `source` VARCHAR(32) NOT NULL DEFAULT 'deterministic', `matched_order_id` INT NULL, `created_order_id` INT NULL, `proposed_order_json` LONGTEXT NULL, `candidate_order_json` LONGTEXT NULL, `reason` TEXT NULL, `created_by` INT NULL, `decided_by` INT NULL, `decided_at` DATETIME NULL, `executed_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_xlvask_automation_usage` (`usage_log_id`), KEY `idx_xlvask_automation_wash` (`wash_id`), KEY `idx_xlvask_automation_signature` (`signature_hash`), KEY `idx_xlvask_automation_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); foreach ([ 'run_id' => 'BIGINT NULL AFTER usage_log_id', 'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source", 'planner_identity_hash' => 'CHAR(64) NULL AFTER policy_version', 'model' => 'VARCHAR(96) NULL AFTER planner_identity_hash', 'model_confidence' => 'DECIMAL(5,4) NULL AFTER model', 'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence', 'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability", 'evidence_json' => 'LONGTEXT NULL AFTER certainty', 'contradictions_json' => 'LONGTEXT NULL AFTER evidence_json', 'risk_flags_json' => 'LONGTEXT NULL AFTER contradictions_json', 'plan_steps_json' => 'LONGTEXT NULL AFTER risk_flags_json', 'expected_version' => 'INT NULL AFTER plan_steps_json', 'input_hash' => 'CHAR(64) NULL AFTER expected_version', ] as $column => $definition) { self::addColumnIfMissing($db, 'xlvask_automation_suggestions', $column, $definition); } $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` ( `id` INT NOT NULL AUTO_INCREMENT, `usage_log_id` INT NULL, `wash_id` VARCHAR(128) NULL, `signature_hash` CHAR(64) NOT NULL, `signature_json` LONGTEXT NULL, `action` VARCHAR(32) NOT NULL, `decision` VARCHAR(32) NOT NULL, `order_id` INT NULL, `reason` TEXT NULL, `created_by` INT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`), KEY `idx_xlvask_feedback_usage` (`usage_log_id`), KEY `idx_xlvask_feedback_decision` (`decision`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` ( `id` INT NOT NULL AUTO_INCREMENT, `cache_key` CHAR(64) NOT NULL, `schema_name` VARCHAR(96) NOT NULL, `input_json` LONGTEXT NOT NULL, `result_json` LONGTEXT NOT NULL, `hits` INT NOT NULL DEFAULT 0, `last_hit_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`), KEY `idx_xlvask_openai_cache_schema` (`schema_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_autopilot_runs` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `idempotency_key` CHAR(64) NOT NULL, `mode` VARCHAR(16) NOT NULL, `status` VARCHAR(24) NOT NULL DEFAULT 'queued', `phase` VARCHAR(32) NOT NULL DEFAULT 'queued', `date_from` DATE NULL, `date_to` DATE NULL, `force_refetch` TINYINT(1) NOT NULL DEFAULT 0, `requested_ids_json` LONGTEXT NULL, `requested_limit` INT NOT NULL DEFAULT 500, `request_hash` CHAR(64) NOT NULL, `scope_hall_ids_json` LONGTEXT NOT NULL, `processed` INT NOT NULL DEFAULT 0, `total` INT NOT NULL DEFAULT 0, `summary_json` LONGTEXT NULL, `warning` TEXT NULL, `error` TEXT NULL, `lease_token` CHAR(36) NULL, `lease_expires_at` DATETIME NULL, `attempt_count` INT NOT NULL DEFAULT 0, `max_attempts` INT NOT NULL DEFAULT 3, `next_attempt_at` DATETIME NULL, `created_by` INT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `started_at` DATETIME NULL, `finished_at` DATETIME NULL, `active_execute_slot` TINYINT GENERATED ALWAYS AS ( CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END ) STORED, PRIMARY KEY (`id`), UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`), UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`), KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_token', 'CHAR(36) NULL AFTER error'); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_expires_at', 'DATETIME NULL AFTER lease_token'); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json"); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json'); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit"); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at'); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count'); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts'); self::addColumnIfMissing( $db, 'xlvask_autopilot_runs', 'active_execute_slot', "TINYINT GENERATED ALWAYS AS (CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END) STORED AFTER finished_at" ); if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_active_execute_run')) { if ($db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`)') === false) { throw new \RuntimeException('The unique active XL Vask execute-run index could not be created.'); } } if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_autopilot_run_idempotency') && $db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)') === false) { throw new \RuntimeException('The unique XL Vask run idempotency index could not be created.'); } $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `run_id` BIGINT NOT NULL, `usage_log_id` INT NULL, `wash_id` VARCHAR(128) NULL, `import_state` VARCHAR(24) NOT NULL DEFAULT 'unchanged', `resolution_state` VARCHAR(32) NOT NULL DEFAULT 'needs_review', `certainty` VARCHAR(16) NOT NULL DEFAULT 'none', `planned_action` VARCHAR(32) NOT NULL DEFAULT 'none', `source_hash` CHAR(64) NULL, `expected_version` INT NULL, `result_json` LONGTEXT NULL, `error` TEXT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_xlvask_autopilot_run_usage` (`run_id`, `usage_log_id`), KEY `idx_xlvask_autopilot_run_item_state` (`run_id`, `resolution_state`, `certainty`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_audit` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `run_id` BIGINT NULL, `usage_log_id` INT NULL, `wash_id` VARCHAR(128) NULL, `event_type` VARCHAR(48) NOT NULL, `action` VARCHAR(32) NULL, `policy_version` VARCHAR(64) NOT NULL, `input_hash` CHAR(64) NULL, `source_revision` VARCHAR(128) NULL, `expected_version` INT NULL, `before_json` LONGTEXT NULL, `after_json` LONGTEXT NULL, `evidence_json` LONGTEXT NULL, `actor_id` INT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_xlvask_audit_usage` (`usage_log_id`, `created_at`), KEY `idx_xlvask_audit_run` (`run_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_calibrations` ( `id` INT NOT NULL AUTO_INCREMENT, `policy_version` VARCHAR(64) NOT NULL, `segment_key` VARCHAR(191) NOT NULL, `precision_value` DECIMAL(7,6) NOT NULL, `wilson_lower_bound` DECIMAL(7,6) NOT NULL, `holdout_examples` INT NOT NULL, `segment_examples` INT NOT NULL, `contradictions` INT NOT NULL DEFAULT 0, `calibrated_probability` DECIMAL(7,6) NOT NULL, `artifact_hash` CHAR(64) NOT NULL, `active` TINYINT(1) NOT NULL DEFAULT 0, `backtest_json` LONGTEXT NULL, `created_by` INT NULL, `activated_by` INT NULL, `activated_at` DATETIME NULL, `invalidated_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`), KEY `idx_xlvask_calibration_lookup` (`policy_version`, `segment_key`, `active`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'backtest_json', 'LONGTEXT NULL AFTER active'); self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json'); self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by'); self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by'); self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'automation_identity_hash', 'CHAR(64) NULL AFTER segment_key'); self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'invalidated_at', 'DATETIME NULL AFTER activated_at'); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `suggestion_id` INT NOT NULL, `outcome` VARCHAR(16) NOT NULL, `adjudication_outcome` VARCHAR(32) NULL, `adjudicated_by` INT NOT NULL, `adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_xlvask_calibration_label_suggestion` (`suggestion_id`), KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); // Immutable adjudication events supersede the legacy one-row-per-suggestion table. // The nullable legacy id supports an idempotent, non-destructive backfill. $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_label_events` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `suggestion_id` INT NOT NULL, `outcome` VARCHAR(16) NOT NULL, `adjudicated_by` INT NOT NULL, `adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `legacy_label_id` BIGINT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uniq_xlvask_calibration_legacy_label` (`legacy_label_id`), KEY `idx_xlvask_calibration_event_suggestion` (`suggestion_id`, `id`), KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); self::addColumnIfMissing( $db, 'xlvask_automation_calibration_label_events', 'adjudication_outcome', 'VARCHAR(32) NULL AFTER outcome' ); $db->query( "INSERT IGNORE INTO xlvask_automation_calibration_label_events (suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id) SELECT suggestion_id, outcome, adjudicated_by, adjudicated_at, id FROM xlvask_automation_calibration_labels" ); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_decision_previews` ( `id` CHAR(36) NOT NULL, `selection_hash` CHAR(64) NOT NULL, `action` VARCHAR(32) NOT NULL, `payload_json` LONGTEXT NOT NULL, `created_by` INT NULL, `expires_at` DATETIME NOT NULL, `applied_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_state` ( `id` TINYINT NOT NULL, `policy_version` VARCHAR(64) NOT NULL, `planner_identity_hash` CHAR(64) NOT NULL, `stage` VARCHAR(32) NOT NULL DEFAULT 'off', `halted` TINYINT(1) NOT NULL DEFAULT 0, `attach_enabled` TINYINT(1) NOT NULL DEFAULT 0, `create_enabled` TINYINT(1) NOT NULL DEFAULT 0, `halt_reason` TEXT NULL, `attach_halt_reason` TEXT NULL, `create_halt_reason` TEXT NULL, `halted_at` DATETIME NULL, `halted_by` INT NULL, `attach_activated_at` DATETIME NULL, `attach_activated_by` INT NULL, `create_activated_at` DATETIME NULL, `create_activated_by` INT NULL, `expected_version` INT NOT NULL DEFAULT 1, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'stage', "VARCHAR(32) NOT NULL DEFAULT 'off' AFTER planner_identity_hash"); self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'attach_halt_reason', 'TEXT NULL AFTER halt_reason'); self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'create_halt_reason', 'TEXT NULL AFTER attach_halt_reason'); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_previews` ( `id` CHAR(36) NOT NULL, `selection_hash` CHAR(64) NOT NULL, `requested_transition` VARCHAR(32) NOT NULL, `payload_json` LONGTEXT NOT NULL, `created_by` INT NOT NULL, `expires_at` DATETIME NOT NULL, `applied_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_xlvask_policy_preview_expiry` (`expires_at`, `applied_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_events` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `event_type` VARCHAR(48) NOT NULL, `actor_id` INT NOT NULL, `details_json` LONGTEXT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_xlvask_policy_event_time` (`created_at`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_action_events` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `suggestion_id` INT NOT NULL, `run_id` BIGINT NULL, `hall_id` VARCHAR(191) NOT NULL, `action` VARCHAR(32) NOT NULL, `source` VARCHAR(32) NOT NULL, `policy_version` VARCHAR(64) NOT NULL, `planner_identity_hash` CHAR(64) NOT NULL, `review_outcome` VARCHAR(32) NULL, `reviewed_by` INT NULL, `reviewed_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`), KEY `idx_xlvask_action_budget` (`action`, `created_at`), KEY `idx_xlvask_action_hall_budget` (`hall_id`, `action`, `created_at`), KEY `idx_xlvask_action_soak` (`source`, `action`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'hall_id', "VARCHAR(191) NOT NULL DEFAULT '' AFTER run_id"); self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'review_outcome', 'VARCHAR(32) NULL AFTER planner_identity_hash'); self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_by', 'INT NULL AFTER review_outcome'); self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_at', 'DATETIME NULL AFTER reviewed_by'); if (!self::indexExists($db, 'xlvask_automation_action_events', 'uniq_xlvask_action_event_suggestion') && $db->query('ALTER TABLE `xlvask_automation_action_events` ADD UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`)') === false) { throw new \RuntimeException('The unique XL Vask action-event suggestion index could not be created.'); } } public static function washIdUniquenessReady(): bool { global $db; if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { return false; } return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id'); } /** * Explicit activation migration. Never call this from constructors, GETs, or normal runs. * Returns false without modifying conflicting records when duplicate wash IDs exist. */ public static function applyWashIdUniquenessMigration(): bool { global $db; if (!self::tableExists($db, 'orders') || !self::columnExists($db, 'orders', 'wash_id')) { return false; } if (self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id')) { return true; } $duplicates = $db->query( "SELECT LOWER(TRIM(`wash_id`)) normalized_wash_id FROM `orders` WHERE `wash_id` IS NOT NULL AND TRIM(`wash_id`) <> '' GROUP BY LOWER(TRIM(`wash_id`)) HAVING COUNT(*) > 1 LIMIT 1" ); if ($duplicates !== false && is_object($duplicates) && (int)$duplicates->num_rows === 0) { if (!self::columnExists($db, 'orders', 'xlvask_normalized_wash_id')) { $db->query( "ALTER TABLE `orders` ADD COLUMN `xlvask_normalized_wash_id` VARCHAR(128) GENERATED ALWAYS AS (NULLIF(LOWER(TRIM(`wash_id`)), '')) STORED" ); } $db->query( "ALTER TABLE `orders` ADD UNIQUE KEY `uniq_orders_xlvask_wash_id` (`xlvask_normalized_wash_id`)" ); return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id'); } return false; } private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void { if (!self::columnExists($db, $table, $column)) { if ($db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}") === false) { throw new \RuntimeException("The required XL Vask column {$table}.{$column} could not be created."); } } } private static function activeExecuteRunConflicts(object $db): array { if (!self::tableExists($db, 'xlvask_autopilot_runs') || !self::columnExists($db, 'xlvask_autopilot_runs', 'mode') || !self::columnExists($db, 'xlvask_autopilot_runs', 'status')) { return []; } $result = $db->query( "SELECT COUNT(*) total FROM xlvask_autopilot_runs WHERE mode = 'execute' AND status IN ('queued', 'running', 'retry_wait')" ); if ($result === false || !is_object($result)) { return ['active_execute_preflight_unavailable']; } $row = $db->fetch_assoc($result); $count = (int)($row['total'] ?? 0); return $count > 1 ? ['multiple_active_execute_runs:' . $count] : []; } private static function tableExists(object $db, string $table): bool { $table = self::escapeIdentifier($table); $result = $db->query("SHOW TABLES LIKE '{$table}'"); if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { return false; } return (int)$result->num_rows > 0; } private static function columnExists(object $db, string $table, string $column): bool { $table = self::escapeIdentifier($table); $column = self::escapeIdentifier($column); $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { return false; } return (int)$result->num_rows > 0; } private static function indexExists(object $db, string $table, string $index): bool { if (!self::tableExists($db, $table)) { return false; } $table = self::escapeIdentifier($table); $index = self::escapeIdentifier($index); $result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); return $result !== false && is_object($result) && (int)$result->num_rows > 0; } private static function escapeIdentifier(string $value): string { return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); } }