Files
api/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php
T
2026-08-04 17:46:15 +02:00

695 lines
37 KiB
PHP

<?php
namespace classes;
/**
* Ensures additive schema for XL Vask usage-log review state.
*/
class xlvask_usage_logs_schema_bootstrap
{
public const MIGRATION_VERSION = '20260804_xlvask_ai_auto_policy_v2';
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
$status = self::migrationStatus();
if (!$status['ready']) {
throw new \RuntimeException(
'XL Vask automation schema is not ready. Apply migration ' . self::MIGRATION_VERSION . ' explicitly.'
);
}
self::$initialized = true;
}
/**
* Explicit operator-invoked migration entrypoint. Request handlers and workers must never call this method.
*/
public static function applyExplicitMigration(): array
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
throw new \RuntimeException('The database connection is unavailable.');
}
if (!self::tableExists($db, 'xlvask_usage_logs')) {
throw new \RuntimeException('The xlvask_usage_logs table is unavailable.');
}
$conflicts = self::activeExecuteRunConflicts($db);
if ($conflicts !== []) {
throw new \RuntimeException(
'XL Vask automation migration is blocked by existing active execute runs: ' . implode(', ', $conflicts)
);
}
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_hash', 'CHAR(64) NULL AFTER cached_amount_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_revision', 'VARCHAR(128) NULL AFTER source_hash');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observed_at', 'DATETIME NULL AFTER source_revision');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_stable_since', 'DATETIME NULL AFTER source_observed_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observation_count', 'INT NOT NULL DEFAULT 0 AFTER source_stable_since');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'import_state', "VARCHAR(24) NOT NULL DEFAULT 'unchanged' AFTER source_observation_count");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'resolution_state', "VARCHAR(32) NOT NULL DEFAULT 'needs_review' AFTER import_state");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'certainty', "VARCHAR(16) NOT NULL DEFAULT 'none' AFTER resolution_state");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'planned_action', "VARCHAR(32) NOT NULL DEFAULT 'none' AFTER certainty");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'state_reason', 'TEXT NULL AFTER planned_action');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'expected_version', 'INT NOT NULL DEFAULT 1 AFTER state_reason');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_run_id', 'BIGINT NULL AFTER expected_version');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id');
self::ensureAutomationTables($db);
self::$initialized = false;
$status = self::migrationStatus();
if (!$status['ready']) {
throw new \RuntimeException('XL Vask automation migration did not reach a ready state.');
}
return $status;
}
/** Read-only preflight used by readiness endpoints and normal request/worker entrypoints. */
public static function migrationStatus(): array
{
global $db;
$missingTables = [];
$missingColumns = [];
$requiredIndexes = [
'xlvask_autopilot_runs.uniq_xlvask_active_execute_run',
'xlvask_autopilot_runs.uniq_xlvask_autopilot_run_idempotency',
'xlvask_automation_action_events.uniq_xlvask_action_event_suggestion',
];
$missingIndexes = [];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return [
'version' => 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',
'ai_timeline', 'ai_batch_size', 'ai_max_cost_usd',
'ai_input_usd_per_1m_usd', 'ai_output_usd_per_1m_usd',
'ai_requests', 'ai_cache_hits', 'ai_input_tokens', 'ai_output_tokens',
'ai_total_tokens', 'ai_estimated_cost_usd', 'ai_budget_exhausted',
],
'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', 'ai_timeline', "VARCHAR(16) NOT NULL DEFAULT 'standard' AFTER scope_hall_ids_json");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_batch_size', 'INT NOT NULL DEFAULT 150 AFTER ai_timeline');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_max_cost_usd', 'DECIMAL(12,4) NULL AFTER ai_batch_size');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 0.5000 AFTER ai_max_cost_usd');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 2.0000 AFTER ai_input_usd_per_1m_usd');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_requests', 'INT NOT NULL DEFAULT 0 AFTER total');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_cache_hits', 'INT NOT NULL DEFAULT 0 AFTER ai_requests');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_cache_hits');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_input_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_total_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_output_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_estimated_cost_usd', 'DECIMAL(14,6) NOT NULL DEFAULT 0.000000 AFTER ai_total_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_budget_exhausted', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER ai_estimated_cost_usd');
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);
}
}