readinessReadOnly($dateFrom, $dateTo, $hallIds); $policy = (array)($readiness['policy'] ?? []); $blocked = (array)($readiness['blocked_reasons'] ?? []); $executeEnabled = in_array((string)($policy['effective_stage'] ?? 'off'), [ 'ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped', ], true) && !(bool)($policy['halted'] ?? false) && (bool)($readiness['ready'] ?? false); return [ 'effective_stage' => (string)($policy['effective_stage'] ?? 'halted'), 'allowed_modes' => ['dry_run', 'replay', ...($executeEnabled ? ['execute'] : [])], 'effective_action_sources' => $executeEnabled && ((bool)($policy['attach_enabled'] ?? false) || (bool)($policy['create_enabled'] ?? false)) ? ['openai'] : [], 'blocked_reasons' => $blocked, 'readiness' => $readiness, 'active_run' => $this->firstActiveRunReadOnly($hallIds), ]; } public function readinessReadOnly(?string $dateFrom = null, ?string $dateTo = null, array $hallIds = []): array { $identity = xlvask_automation_service::automationIdentityForAutomation(); $state = $this->policyStateReadOnly(); $calibrations = $this->activeCalibrationSnapshotReadOnly(); $budgets = $this->budgetSnapshotReadOnly($hallIds, $state); $runtime = $this->runtimeConfigReadOnly(); $activeRun = $this->firstActiveRunReadOnly($hallIds); $scopeValid = self::scopeDatesAreValid($dateFrom, $dateTo); $eligibleCounts = $scopeValid ? $this->eligibleCountsReadOnly($dateFrom, $dateTo, $hallIds) : ['attach_order' => 0, 'create_order' => 0, 'total' => 0]; $blocked = []; $migration = xlvask_usage_logs_schema_bootstrap::migrationStatus(); $stage = $state === null ? 'off' : ((bool)($state['halted'] ?? false) ? 'halted' : (string)($state['stage'] ?? 'off')); if (!$migration['ready']) { $blocked[] = 'automation_schema_not_ready'; } if (!$scopeValid) { $blocked[] = 'invalid_invoice_period_scope'; } if (!xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) { $blocked[] = 'wash_id_uniqueness_not_ready'; } if (!$runtime['xlvask_enabled']) { $blocked[] = 'xlvask_module_disabled'; } if (!$runtime['openai_enabled']) { $blocked[] = 'openai_disabled'; } if ($state !== null && !hash_equals((string)$state['planner_identity_hash'], (string)$identity['identity_hash'])) { $blocked[] = 'planner_identity_changed'; } $policy = $this->formatPolicy($state, $runtime, $budgets, $calibrations, $identity); if ($stage === 'halted') { $blocked[] = 'automation_halted'; } $requiredSegments = []; if (in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true)) { $requiredSegments[] = 'openai:attach_order'; } if (in_array($stage, ['ai_create_canary', 'verified_capped'], true)) { $requiredSegments[] = 'openai:create_order'; } foreach ($requiredSegments as $segment) { if (!isset($calibrations[$segment])) { $blocked[] = 'calibration_missing:' . $segment; } } $blocked = array_values(array_unique($blocked)); return [ 'ready' => $blocked === [], 'blocked_reasons' => $blocked, 'policy' => $policy, 'workers' => [ 'active_execute_run' => $activeRun, 'queue_contract' => 'xlvask_autopilot_queue_every_60_seconds', 'health' => $activeRun === null ? 'idle' : 'active', ], 'worker_healthy' => (bool)$migration['ready'] && !$this->hasExpiredRunLeaseReadOnly(), 'calibrations' => array_values($calibrations), 'budgets' => $budgets, 'review_progress' => [ 'attach_order' => [ 'reviewed' => (int)$budgets['soak']['reviewed_correct_links'], 'target' => self::LINK_SOAK_TARGET, 'since' => $state['attach_activated_at'] ?? null, ], 'create_order' => [ 'reviewed' => (int)$budgets['soak']['reviewed_correct_creations'], 'target' => self::CREATE_SOAK_TARGET, 'since' => $state['create_activated_at'] ?? null, ], ], 'planner_identity' => $identity, 'policy_version' => (string)$identity['policy_version'], 'model' => (string)$identity['model'], 'wash_id_uniqueness_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(), 'migration' => $migration, 'eligible_counts' => $eligibleCounts, ]; } public function activeRunReadOnly(array $hallIds = []): ?array { return $this->firstActiveRunReadOnly($hallIds); } public function createPolicyPreview(string $targetStage, string $reason, ?int $actorId): array { global $db; if ($actorId === null) { throw new Exception('An authenticated administrator is required.'); } $targetStage = strtolower(trim($targetStage)); $reason = mb_substr(trim($reason), 0, 1000); if (!in_array($targetStage, array_values(array_diff(self::STAGES, ['halted'])), true)) { throw new Exception('Invalid XL Vask automation policy transition.'); } if ($reason === '') { throw new Exception('A reason is required for the XL Vask automation policy transition.'); } xlvask_usage_logs_schema_bootstrap::ensureTables(); $this->ensurePolicyState(); $readiness = $this->readinessReadOnly(); $this->assertTransitionAllowed($targetStage, $readiness); $state = (array)$this->policyStateReadOnly(); $snapshot = $this->policySnapshotForTransition($targetStage, $state, $readiness, $reason); $id = self::uuidV4(); $selectionHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($snapshot)); $confirmation = 'APPLY-XLVASK-' . strtoupper(str_replace('_', '-', $targetStage)) . '-' . $id; $payload = $snapshot + ['confirmation_phrase' => $confirmation]; $payloadJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($payload)); $idSql = $db->escape_string($id); $hashSql = $db->escape_string($selectionHash); $transitionSql = $db->escape_string($targetStage); if ($db->query( "INSERT INTO xlvask_automation_policy_previews (id, selection_hash, requested_transition, payload_json, created_by, expires_at) VALUES ('{$idSql}', '{$hashSql}', '{$transitionSql}', '{$payloadJson}', {$actorId}, DATE_ADD(NOW(), INTERVAL " . self::PREVIEW_TTL_SECONDS . " SECOND))" ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('The XL Vask automation policy preview could not be stored.'); } return [ 'id' => $id, 'selection_hash' => $selectionHash, 'confirmation_phrase' => $confirmation, 'requires_confirmation' => true, 'expires_at' => date('c', time() + self::PREVIEW_TTL_SECONDS), 'requested_transition' => $targetStage, 'reason' => $reason, 'expected_policy_version' => (int)($state['expected_version'] ?? 0), 'readiness_snapshot' => $readiness, ]; } public function applyPolicyPreview(array $input, ?int $actorId): array { global $db; $previewId = trim((string)($input['preview_id'] ?? '')); $selectionHash = trim((string)($input['selection_hash'] ?? '')); $confirmation = (string)($input['confirmation_text'] ?? ''); if ($actorId === null || !preg_match('/^[0-9a-f-]{36}$/i', $previewId) || !preg_match('/^[0-9a-f]{64}$/', $selectionHash)) { throw new Exception('Invalid XL Vask automation policy preview identifiers.'); } xlvask_usage_logs_schema_bootstrap::ensureTables(); $this->ensurePolicyState(); $connection = $db->conn(); $connection->begin_transaction(); try { $idSql = $db->escape_string($previewId); $result = $db->query("SELECT * FROM xlvask_automation_policy_previews WHERE id = '{$idSql}' FOR UPDATE"); $preview = $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null; if ($preview === null || (int)$preview['created_by'] !== $actorId || !hash_equals((string)$preview['selection_hash'], $selectionHash) || !empty($preview['applied_at']) || strtotime((string)$preview['expires_at']) <= time()) { throw new Exception('XL Vask automation policy preview is invalid, expired, or already applied.'); } $payload = json_decode((string)$preview['payload_json'], true); if (!is_array($payload) || !hash_equals((string)($payload['confirmation_phrase'] ?? ''), trim($confirmation))) { throw new Exception('XL Vask automation policy confirmation text is invalid.'); } $stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE'); $state = $stateResult !== false && $stateResult->num_rows > 0 ? $db->fetch_assoc($stateResult) : null; if ($state === null || (int)$state['expected_version'] !== (int)($payload['expected_policy_version'] ?? -1)) { throw new Exception('XL Vask automation policy changed after preview.'); } $transition = (string)$preview['requested_transition']; $readiness = $this->readinessReadOnly(); $this->assertTransitionAllowed($transition, $readiness); $reason = mb_substr(trim((string)($payload['reason'] ?? '')), 0, 1000); if ($reason === '') { throw new Exception('The XL Vask automation policy transition reason is missing.'); } $currentSnapshot = $this->policySnapshotForTransition($transition, $state, $readiness, $reason); if (!hash_equals($selectionHash, hash('sha256', xlvask_automation_service::stableJsonForAutomation($currentSnapshot)))) { throw new Exception('XL Vask automation readiness changed after preview.'); } $assignments = $this->assignmentsForTransition($transition, $actorId); if ($db->query( 'UPDATE xlvask_automation_policy_state SET ' . implode(', ', $assignments) . ', expected_version = expected_version + 1 WHERE id = 1 AND expected_version = ' . (int)$state['expected_version'] ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('XL Vask automation policy could not be updated atomically.'); } $this->syncLegacyKillSwitchesForStage($transition); $this->recordPolicyEvent($transition, $actorId, [ 'selection_hash' => $selectionHash, 'reason' => $reason, ]); if ($db->query("UPDATE xlvask_automation_policy_previews SET applied_at = NOW() WHERE id = '{$idSql}' AND applied_at IS NULL") === false || $db->conn()->affected_rows !== 1) { throw new Exception('XL Vask automation policy preview could not be finalized.'); } $connection->commit(); } catch (Throwable $throwable) { $connection->rollback(); throw $throwable; } return ['policy' => $this->readinessReadOnly()['policy'], 'readiness' => $this->readinessReadOnly()]; } public function halt(?int $actorId, string $reason): array { global $db; if ($actorId === null) { throw new Exception('An authenticated administrator is required.'); } xlvask_usage_logs_schema_bootstrap::ensureTables(); $this->ensurePolicyState(); $connection = $db->conn(); $connection->begin_transaction(); try { $result = $db->query('SELECT expected_version FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE'); if ($result === false || $result->num_rows < 1) { throw new Exception('XL Vask automation policy is unavailable.'); } $reason = mb_substr(trim($reason), 0, 1000); $reasonSql = $reason === '' ? 'NULL' : "'" . $db->escape_string($reason) . "'"; if ($db->query( "UPDATE xlvask_automation_policy_state SET stage = 'halted', halted = 1, attach_enabled = 0, create_enabled = 0, halt_reason = {$reasonSql}, halted_at = NOW(), halted_by = {$actorId}, expected_version = expected_version + 1 WHERE id = 1" ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('XL Vask automation could not be halted atomically.'); } $this->syncLegacyKillSwitchesForStage('halted'); $this->recordPolicyEvent('halt', $actorId, [ 'reason' => $reason === '' ? null : $reason, 'reason_present' => $reason !== '', ]); $connection->commit(); } catch (Throwable $throwable) { $connection->rollback(); throw $throwable; } return ['policy' => $this->readinessReadOnly()['policy'], 'readiness' => $this->readinessReadOnly()]; } /** Called inside the order mutation transaction; rollback also releases the cap reservation. */ public function reserveAutomaticAction(array $suggestion, array $context): void { global $db; $action = (string)($suggestion['action'] ?? ''); if (!in_array($action, ['attach_order', 'create_order'], true)) { throw new Exception('Unsupported XL Vask automatic action.'); } $stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE'); $state = $stateResult !== false && $stateResult->num_rows > 0 ? $db->fetch_assoc($stateResult) : null; $identity = xlvask_automation_service::automationIdentityForAutomation(); if ($state === null || (bool)$state['halted'] || !hash_equals((string)$state['policy_version'], (string)$identity['policy_version']) || !hash_equals((string)$state['planner_identity_hash'], (string)$identity['identity_hash'])) { throw new xlvask_automation_control_stop( 'policy_halted_or_identity_changed', 'XL Vask automatic actions are halted or the planner identity changed.' ); } if (($action === 'attach_order' && !(bool)$state['attach_enabled']) || ($action === 'create_order' && (!(bool)$state['create_enabled'] || !(bool)$state['attach_enabled']))) { throw new xlvask_automation_control_stop( 'policy_action_disabled', 'The XL Vask automatic action is not activated by server policy.' ); } if (!hash_equals((string)($suggestion['planner_identity_hash'] ?? ''), (string)$identity['identity_hash'])) { throw new xlvask_automation_control_stop( 'planner_identity_changed', 'The XL Vask suggestion planner identity is stale.' ); } if ((string)($suggestion['source'] ?? '') !== 'openai' || !hash_equals((string)($suggestion['model'] ?? ''), (string)$identity['model'])) { throw new xlvask_automation_control_stop( 'planner_identity_changed', 'The XL Vask suggestion resolved model does not match the calibrated planner identity.' ); } $segment = (string)($suggestion['source'] ?? '') . ':' . $action; if (!$this->activeCalibrationMatchesIdentity($segment, (string)$identity['identity_hash'])) { throw new xlvask_automation_control_stop( 'calibration_revoked', 'The XL Vask automatic action lacks an exact active calibration artifact.' ); } $runtime = $this->runtimeConfigReadOnly(); if (!$runtime['xlvask_enabled'] || !$runtime['openai_enabled'] || ($action === 'attach_order' && !$runtime['attachment_config_enabled']) || ($action === 'create_order' && !$runtime['creation_config_enabled'])) { throw new xlvask_automation_control_stop( 'runtime_disabled', 'The XL Vask automatic action was disabled by runtime configuration.' ); } $hallId = trim((string)($context['hall_id'] ?? '')); if ($hallId === '' || strlen($hallId) > 191) { throw new Exception('The XL Vask automatic action lacks a valid hall scope.'); } $budget = $this->budgetCountsForAction($action, $hallId, [$hallId]); [$dailyCap, $perHallDailyCap] = $action === 'attach_order' ? [self::ATTACH_DAILY_CAP, self::ATTACH_PER_HALL_DAILY_CAP] : [self::CREATE_DAILY_CAP, self::CREATE_PER_HALL_DAILY_CAP]; if ($budget['global_last_24_hours'] >= $dailyCap || $budget['hall_last_24_hours'] >= $perHallDailyCap) { throw new xlvask_automation_control_stop( 'budget_exhausted', 'The XL Vask automatic action rolling budget is exhausted.' ); } if ($action === 'create_order' && $this->reviewedCorrectCount('attach_order', $state['attach_activated_at'] ?? null) < self::LINK_SOAK_TARGET) { throw new xlvask_automation_control_stop( 'attach_soak_incomplete', 'Automatic order creation is blocked until the link soak target is complete.' ); } $suggestionId = (int)($suggestion['id'] ?? 0); $runSql = isset($suggestion['run_id']) && $suggestion['run_id'] !== null ? (string)(int)$suggestion['run_id'] : 'NULL'; $sourceSql = $db->escape_string((string)($suggestion['source'] ?? '')); if ($suggestionId < 1 || $db->query( "INSERT INTO xlvask_automation_action_events (suggestion_id, run_id, hall_id, action, source, policy_version, planner_identity_hash) VALUES ({$suggestionId}, {$runSql}, '" . $db->escape_string($hallId) . "', '" . $db->escape_string($action) . "', '{$sourceSql}', '" . $db->escape_string((string)$identity['policy_version']) . "', '" . $db->escape_string((string)$identity['identity_hash']) . "')" ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('The XL Vask automatic action budget could not be reserved atomically.'); } } public function policyAllowsActionReadOnly(string $action): bool { $readiness = $this->readinessReadOnly(); $policy = (array)($readiness['policy'] ?? []); if (!(bool)($readiness['ready'] ?? false)) { return false; } if ($action === 'attach_order') { return !(bool)($policy['halted'] ?? true) && (bool)($policy['attach_enabled'] ?? false); } if ($action === 'create_order') { return !(bool)($policy['halted'] ?? true) && (bool)($policy['attach_enabled'] ?? false) && (bool)($policy['create_enabled'] ?? false) && (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) >= self::LINK_SOAK_TARGET; } return false; } public function reviewAutomaticActionBySuggestion( int $suggestionId, string $outcome, int $actorId, bool $joinExistingTransaction = false ): array { global $db; if ($suggestionId < 1 || $actorId < 1 || !in_array($outcome, ['correct', 'incorrect', 'duplicate', 'cross_hall', 'unaudited'], true)) { throw new Exception('Invalid XL Vask automatic action review.'); } xlvask_usage_logs_schema_bootstrap::ensureTables(); $connection = $db->conn(); if (!$joinExistingTransaction) { $connection->begin_transaction(); } try { $result = $db->query( "SELECT * FROM xlvask_automation_action_events WHERE suggestion_id = {$suggestionId} FOR UPDATE" ); if ($result === false || $result->num_rows < 1) { if (!$joinExistingTransaction) { $connection->commit(); } return [ 'suggestion_id' => $suggestionId, 'automatic_action_reviewed' => false, 'action_halted' => false, 'affected_action' => null, ]; } $event = $db->fetch_assoc($result); if (!empty($event['reviewed_at'])) { if (self::adjudicationRetryMatches((string)($event['review_outcome'] ?? ''), $outcome)) { if (!$joinExistingTransaction) { $connection->commit(); } return [ 'suggestion_id' => $suggestionId, 'automatic_action_reviewed' => true, 'outcome' => $outcome, 'action' => (string)$event['action'], 'idempotent' => true, 'action_halted' => $outcome !== 'correct', 'affected_action' => $outcome !== 'correct' ? (string)$event['action'] : null, ]; } throw new Exception('The XL Vask automatic action outcome was already reviewed differently.'); } if ($db->query( "UPDATE xlvask_automation_action_events SET review_outcome = '" . $db->escape_string($outcome) . "', reviewed_by = {$actorId}, reviewed_at = NOW() WHERE id = " . (int)$event['id'] . " AND reviewed_at IS NULL" ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('The XL Vask automatic action review could not be stored atomically.'); } if ($outcome !== 'correct') { $this->haltActionLatchWithinTransaction((string)$event['action'], $outcome, $actorId); } if (!$joinExistingTransaction) { $connection->commit(); } return [ 'suggestion_id' => $suggestionId, 'automatic_action_reviewed' => true, 'outcome' => $outcome, 'action' => (string)$event['action'], 'action_halted' => $outcome !== 'correct', 'affected_action' => $outcome !== 'correct' ? (string)$event['action'] : null, ]; } catch (Throwable $throwable) { if (!$joinExistingTransaction) { $connection->rollback(); } throw $throwable; } } public static function adjudicationRetryMatches(?string $existingOutcome, string $requestedOutcome): bool { return $existingOutcome !== null && $existingOutcome !== '' && hash_equals($existingOutcome, $requestedOutcome); } public function haltActionForCriticalInvariant(string $action, string $reason): void { global $db; if (!in_array($action, ['attach_order', 'create_order'], true)) { return; } try { $connection = $db->conn(); $connection->begin_transaction(); $this->haltActionLatchWithinTransaction($action, 'critical_invariant:' . mb_substr($reason, 0, 120), 0); $connection->commit(); } catch (Throwable $throwable) { try { $db->conn()->rollback(); } catch (Throwable) { } try { $xlvask = new xlvask(); if ($action === 'attach_order') { $xlvask->config->automatic_order_attachment_enabled->setVariableValue(false); } $xlvask->config->automatic_order_creation_enabled->setVariableValue(false); } catch (Throwable) { } error_log('[xlvask-automation] Failed to persist critical invariant latch.'); } } private function haltActionLatchWithinTransaction(string $action, string $reason, int $actorId): void { global $db; $stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE'); if ($stateResult === false || $stateResult->num_rows < 1) { throw new Exception('XL Vask automation policy is unavailable.'); } $reasonSql = $db->escape_string(mb_substr($reason, 0, 1000)); if ($action === 'attach_order') { $assignments = "stage = 'advisory', attach_enabled = 0, create_enabled = 0, attach_halt_reason = '{$reasonSql}'"; $stage = 'advisory'; } else { $assignments = "stage = 'ai_attach_verified', create_enabled = 0, create_halt_reason = '{$reasonSql}'"; $stage = 'ai_attach_verified'; } if ($db->query( "UPDATE xlvask_automation_policy_state SET {$assignments}, expected_version = expected_version + 1 WHERE id = 1" ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('The XL Vask action latch could not be halted atomically.'); } $segment = 'openai:' . $action; if ($db->query( "UPDATE xlvask_automation_calibrations SET active = 0, invalidated_at = NOW() WHERE active = 1 AND segment_key = '" . $db->escape_string($segment) . "'" ) === false) { throw new Exception('The stale XL Vask calibration could not be invalidated atomically.'); } $this->syncLegacyKillSwitchesForStage($stage); $this->recordPolicyEvent('action_latch_halted', $actorId, [ 'action' => $action, 'reason' => $reason, 'invalidated_calibration_segment' => $segment, ]); } private function assertTransitionAllowed(string $targetStage, array $readiness): void { $policy = (array)($readiness['policy'] ?? []); $currentStage = (string)($policy['effective_stage'] ?? 'off'); if (in_array($targetStage, ['off', 'advisory'], true)) { return; } $allowedNext = [ 'advisory' => 'ai_attach_canary', 'ai_attach_canary' => 'ai_attach_verified', 'ai_attach_verified' => 'ai_create_canary', 'ai_create_canary' => 'verified_capped', ]; $blocking = []; if (($allowedNext[$currentStage] ?? null) !== $targetStage) { $blocking[] = 'invalid_stage_transition:' . $currentStage . ':' . $targetStage; } if (($readiness['workers']['active_execute_run'] ?? null) !== null) { $blocking[] = 'active_execute_run'; } foreach (['automation_schema_not_ready', 'wash_id_uniqueness_not_ready', 'xlvask_module_disabled', 'openai_disabled', 'planner_identity_changed'] as $reason) { if (in_array($reason, (array)($readiness['blocked_reasons'] ?? []), true)) { $blocking[] = $reason; } } $calibrationSegments = array_column((array)($readiness['calibrations'] ?? []), 'segment_key'); if (in_array($targetStage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true) && !in_array('openai:attach_order', $calibrationSegments, true)) { $blocking[] = 'calibration_missing:openai:attach_order'; } if (in_array($targetStage, ['ai_create_canary', 'verified_capped'], true) && !in_array('openai:create_order', $calibrationSegments, true)) { $blocking[] = 'calibration_missing:openai:create_order'; } if ($targetStage === 'ai_attach_verified' && (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) < self::LINK_SOAK_TARGET) { $blocking[] = 'attach_soak_incomplete'; } if ($targetStage === 'ai_create_canary' && (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) < self::LINK_SOAK_TARGET) { $blocking[] = 'attach_soak_incomplete'; } if ($targetStage === 'verified_capped' && (int)($readiness['review_progress']['create_order']['reviewed'] ?? 0) < self::CREATE_SOAK_TARGET) { $blocking[] = 'create_soak_incomplete'; } if ($blocking !== []) { throw new Exception('XL Vask policy transition is blocked: ' . implode(', ', array_unique($blocking))); } } private function assignmentsForTransition(string $targetStage, int $actorId): array { global $db; $stageSql = "stage = '" . $targetStage . "'"; $identity = xlvask_automation_service::automationIdentityForAutomation(); $identityAssignments = [ "policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "'", "planner_identity_hash = '" . $db->escape_string((string)$identity['identity_hash']) . "'", ]; return match ($targetStage) { 'off', 'advisory' => [ $stageSql, ...$identityAssignments, 'halted = 0', 'attach_enabled = 0', 'create_enabled = 0', 'halt_reason = NULL', 'halted_at = NULL', 'halted_by = NULL', ], 'ai_attach_canary' => [ $stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 0', 'attach_activated_at = NOW()', "attach_activated_by = {$actorId}", 'attach_halt_reason = NULL', ], 'ai_attach_verified' => [ $stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 0', 'attach_halt_reason = NULL', ], 'ai_create_canary' => [ $stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 1', 'create_activated_at = NOW()', "create_activated_by = {$actorId}", 'create_halt_reason = NULL', ], 'verified_capped' => [ $stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 1', 'create_halt_reason = NULL', ], default => throw new Exception('Invalid XL Vask policy transition.'), }; } private function policySnapshotForTransition(string $transition, array $state, array $readiness, string $reason): array { return [ 'requested_transition' => $transition, 'reason' => $reason, 'expected_policy_version' => (int)($state['expected_version'] ?? 0), 'planner_identity_hash' => (string)($readiness['planner_identity']['identity_hash'] ?? ''), 'wash_id_uniqueness_ready' => (bool)($readiness['wash_id_uniqueness_ready'] ?? false), 'active_execute_run_id' => $readiness['workers']['active_execute_run']['id'] ?? null, 'calibration_artifacts' => array_map(static fn(array $item): array => [ 'segment_key' => (string)$item['segment_key'], 'artifact_hash' => (string)$item['artifact_hash'], 'automation_identity_hash' => (string)($item['automation_identity_hash'] ?? ''), ], (array)($readiness['calibrations'] ?? [])), 'review_progress' => $readiness['review_progress'] ?? [], ]; } private function ensurePolicyState(): void { global $db; $identity = xlvask_automation_service::automationIdentityForAutomation(); $db->query( "INSERT IGNORE INTO xlvask_automation_policy_state (id, policy_version, planner_identity_hash, stage, halted, attach_enabled, create_enabled) VALUES (1, '" . $db->escape_string((string)$identity['policy_version']) . "', '" . $db->escape_string((string)$identity['identity_hash']) . "', 'off', 0, 0, 0)" ); } private function policyStateReadOnly(): ?array { global $db; try { $result = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 LIMIT 1'); return $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null; } catch (Throwable) { return null; } } private function activeCalibrationSnapshotReadOnly(): array { global $db; $identity = xlvask_automation_service::automationIdentityForAutomation(); $verified = []; try { $result = $db->query( "SELECT * FROM xlvask_automation_calibrations WHERE active = 1 AND invalidated_at IS NULL AND policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "' ORDER BY segment_key" ); foreach ($result === false ? [] : $db->fetch_all($result) as $row) { $artifact = json_decode((string)($row['backtest_json'] ?? ''), true); if (!is_array($artifact) || !hash_equals((string)($row['artifact_hash'] ?? ''), hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact))) || !hash_equals((string)($artifact['automation_identity_hash'] ?? ''), (string)$identity['identity_hash']) || !hash_equals((string)($artifact['resolved_model'] ?? ''), (string)$identity['model']) || xlvask_automation_service::classifyCertaintyForAutomation([...$artifact, 'active' => true]) !== 'certain') { continue; } $verified[(string)$row['segment_key']] = [ 'id' => (int)$row['id'], 'segment_key' => (string)$row['segment_key'], 'artifact_hash' => (string)$row['artifact_hash'], 'automation_identity_hash' => (string)$artifact['automation_identity_hash'], 'resolved_model' => (string)$artifact['resolved_model'], 'precision_value' => (float)$row['precision_value'], 'wilson_lower_bound' => (float)$row['wilson_lower_bound'], 'holdout_examples' => (int)$row['holdout_examples'], 'segment_examples' => (int)$row['segment_examples'], 'contradictions' => (int)$row['contradictions'], 'activated_at' => $row['activated_at'] ?? null, ]; } } catch (Throwable) { return []; } return $verified; } private function activeCalibrationMatchesIdentity(string $segment, string $identityHash): bool { $calibrations = $this->activeCalibrationSnapshotReadOnly(); return isset($calibrations[$segment]) && hash_equals((string)$calibrations[$segment]['automation_identity_hash'], $identityHash); } private function firstActiveRunReadOnly(array $hallIds = []): ?array { global $db; try { $result = $db->query( "SELECT id, mode, status, phase, processed, total, created_by, created_at, started_at, scope_hall_ids_json FROM xlvask_autopilot_runs WHERE mode = 'execute' AND status IN ('queued', 'running', 'retry_wait') ORDER BY created_at ASC, id ASC LIMIT 100" ); if ($result === false || $result->num_rows < 1) { return null; } foreach ($db->fetch_all($result) as $row) { $scope = json_decode((string)($row['scope_hall_ids_json'] ?? '[]'), true); $scope = is_array($scope) ? array_map('strval', $scope) : []; if ($hallIds !== [] && array_intersect($scope, array_map('strval', $hallIds)) === []) { continue; } return [ 'id' => (int)$row['id'], 'mode' => (string)$row['mode'], 'status' => (string)$row['status'], 'phase' => (string)$row['phase'], 'processed' => (int)$row['processed'], 'total' => (int)$row['total'], 'created_at' => $row['created_at'] ?? null, 'started_at' => $row['started_at'] ?? null, ]; } return null; } catch (Throwable) { return null; } } private function hasExpiredRunLeaseReadOnly(): bool { global $db; try { $result = $db->query( "SELECT id FROM xlvask_autopilot_runs WHERE status = 'running' AND lease_expires_at IS NOT NULL AND lease_expires_at < NOW() LIMIT 1" ); return $result === false || $result->num_rows > 0; } catch (Throwable) { return true; } } private function eligibleCountsReadOnly(?string $dateFrom, ?string $dateTo, array $hallIds): array { global $db; $hallIds = array_values(array_unique(array_filter(array_map( static fn(mixed $value): string => trim((string)$value), $hallIds ), static fn(string $value): bool => $value !== '' && strlen($value) <= 191))); if ($hallIds === []) { return ['attach_order' => 0, 'create_order' => 0, 'total' => 0]; } try { $identity = xlvask_automation_service::automationIdentityForAutomation(); $state = $this->policyStateReadOnly(); $runtime = $this->runtimeConfigReadOnly(); $stage = $state === null || (bool)($state['halted'] ?? false) ? 'halted' : (string)($state['stage'] ?? 'off'); $attachEnabled = in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true) && (bool)($state['attach_enabled'] ?? false) && $runtime['attachment_config_enabled']; $createEnabled = in_array($stage, ['ai_create_canary', 'verified_capped'], true) && (bool)($state['create_enabled'] ?? false) && $runtime['creation_config_enabled']; if (!$attachEnabled && !$createEnabled) { return ['attach_order' => 0, 'create_order' => 0, 'total' => 0]; } $allowedActions = array_filter([ $attachEnabled ? 'attach_order' : null, $createEnabled ? 'create_order' : null, ]); $where = [ "u.resolution_state = 'needs_review'", "u.import_state <> 'invalid'", 'u.ignored_at IS NULL', 'u.FinishStatus = 1', 'u.source_hash IS NOT NULL', 'u.source_hash <> \'\'', 'u.source_observation_count >= 2', 'u.source_observed_at IS NOT NULL', 'u.source_stable_since <= DATE_SUB(NOW(), INTERVAL 6 HOUR)', "s.status = 'suggested'", "s.source = 'openai'", "s.certainty = 'certain'", "s.policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "'", "s.planner_identity_hash = '" . $db->escape_string((string)$identity['identity_hash']) . "'", "s.model = '" . $db->escape_string((string)$identity['model']) . "'", 's.expected_version = u.expected_version', 's.input_hash = u.source_hash', "s.action IN ('" . implode("','", array_map([$db, 'escape_string'], $allowedActions)) . "')", 'NOT EXISTS (SELECT 1 FROM xlvask_automation_suggestions newer WHERE newer.usage_log_id = s.usage_log_id AND newer.id > s.id)', 'u.HallId IN (' . implode(',', array_map( static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'", $hallIds )) . ')', ]; if ($dateFrom !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom)) { $where[] = "STR_TO_DATE(REPLACE(SUBSTRING(u.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') >= '" . $db->escape_string($dateFrom) . " 00:00:00'"; } if ($dateTo !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) { $where[] = "STR_TO_DATE(REPLACE(SUBSTRING(u.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') <= '" . $db->escape_string($dateTo) . " 23:59:59'"; } $row = $db->fetch_assoc($db->query( "SELECT SUM(s.action = 'attach_order') attach_order, SUM(s.action = 'create_order') create_order FROM xlvask_usage_logs u INNER JOIN xlvask_automation_suggestions s ON s.usage_log_id = u.id WHERE " . implode(' AND ', $where) )); $attach = (int)($row['attach_order'] ?? 0); $create = (int)($row['create_order'] ?? 0); return ['attach_order' => $attach, 'create_order' => $create, 'total' => $attach + $create]; } catch (Throwable) { return ['attach_order' => 0, 'create_order' => 0, 'total' => 0]; } } public static function scopeDatesAreValid(?string $dateFrom, ?string $dateTo): bool { $validDate = static function (?string $value): bool { if ($value === null) { return true; } $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); return $parsed !== false && $parsed->format('Y-m-d') === $value; }; return $validDate($dateFrom) && $validDate($dateTo) && ($dateFrom === null || $dateTo === null || $dateFrom <= $dateTo); } private function budgetSnapshotReadOnly(array $hallIds, ?array $state): array { $attach = $this->budgetCountsForAction('attach_order', null, $hallIds); $create = $this->budgetCountsForAction('create_order', null, $hallIds); $attachBudget = $attach + [ 'global_rolling_24h_cap' => self::ATTACH_DAILY_CAP, 'per_hall_rolling_24h_cap' => self::ATTACH_PER_HALL_DAILY_CAP, 'remaining_global' => max(0, self::ATTACH_DAILY_CAP - (int)$attach['global_last_24_hours']), 'remaining_hall' => max(0, self::ATTACH_PER_HALL_DAILY_CAP - (int)$attach['max_hall_last_24_hours']), ]; $createBudget = $create + [ 'global_rolling_24h_cap' => self::CREATE_DAILY_CAP, 'per_hall_rolling_24h_cap' => self::CREATE_PER_HALL_DAILY_CAP, 'remaining_global' => max(0, self::CREATE_DAILY_CAP - (int)$create['global_last_24_hours']), 'remaining_hall' => max(0, self::CREATE_PER_HALL_DAILY_CAP - (int)$create['max_hall_last_24_hours']), ]; return [ 'attach_order' => $attachBudget, 'create_order' => $createBudget, 'soak' => [ 'reviewed_correct_links' => $this->reviewedCorrectCount('attach_order', $state['attach_activated_at'] ?? null), 'reviewed_correct_creations' => $this->reviewedCorrectCount('create_order', $state['create_activated_at'] ?? null), ], ]; } private function budgetCountsForAction(string $action, ?string $hallId = null, array $visibleHallIds = []): array { global $db; try { $actionSql = $db->escape_string($action); $hallSql = $hallId === null ? null : $db->escape_string($hallId); $visibleHallIds = array_values(array_unique(array_filter(array_map( static fn(mixed $value): string => trim((string)$value), $visibleHallIds ), static fn(string $value): bool => $value !== '' && strlen($value) <= 191))); $visibleHallWhere = $visibleHallIds === [] ? '' : ' AND hall_id IN (' . implode(',', array_map( static fn(string $value): string => "'" . $db->escape_string($value) . "'", $visibleHallIds )) . ')'; $row = $db->fetch_assoc($db->query( "SELECT COUNT(*) global_last_24_hours, SUM(" . ($hallSql === null ? '0' : "hall_id = '{$hallSql}'") . ") hall_last_24_hours FROM xlvask_automation_action_events WHERE action = '{$actionSql}' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)" )); $maxHallRow = $db->fetch_assoc($db->query( "SELECT COALESCE(MAX(hall_total), 0) max_hall_last_24_hours FROM ( SELECT hall_id, COUNT(*) hall_total FROM xlvask_automation_action_events WHERE action = '{$actionSql}' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR){$visibleHallWhere} GROUP BY hall_id ) hall_budgets" )); return [ 'global_last_24_hours' => (int)($row['global_last_24_hours'] ?? 0), 'hall_last_24_hours' => (int)($row['hall_last_24_hours'] ?? 0), 'max_hall_last_24_hours' => (int)($maxHallRow['max_hall_last_24_hours'] ?? 0), ]; } catch (Throwable) { return ['global_last_24_hours' => PHP_INT_MAX, 'hall_last_24_hours' => PHP_INT_MAX, 'max_hall_last_24_hours' => PHP_INT_MAX]; } } private function reviewedCorrectCount(string $action, ?string $since): int { global $db; try { $actionSql = $db->escape_string($action); if ($since === null || trim($since) === '') { return 0; } $sinceSql = $db->escape_string($since); $row = $db->fetch_assoc($db->query( "SELECT COUNT(*) total FROM xlvask_automation_action_events WHERE action = '{$actionSql}' AND source = 'openai' AND review_outcome = 'correct' AND reviewed_at IS NOT NULL AND created_at >= '{$sinceSql}'" )); return (int)($row['total'] ?? 0); } catch (Throwable) { return 0; } } private function runtimeConfigReadOnly(): array { try { $xlvask = new xlvask(); $openai = new openai(); return [ 'xlvask_enabled' => $xlvask->config->enabled->isTrue(), 'openai_enabled' => $xlvask->config->openai_integration_enabled->isTrue() && $openai->config->enabled->isTrue(), 'attachment_config_enabled' => $xlvask->config->automatic_order_attachment_enabled->isTrue(), 'creation_config_enabled' => $xlvask->config->automatic_order_creation_enabled->isTrue(), ]; } catch (Throwable) { return [ 'xlvask_enabled' => false, 'openai_enabled' => false, 'attachment_config_enabled' => false, 'creation_config_enabled' => false, ]; } } private function syncLegacyKillSwitchesForStage(string $stage): void { $xlvask = new xlvask(); $attach = in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true); $create = in_array($stage, ['ai_create_canary', 'verified_capped'], true); $xlvask->config->automatic_order_attachment_enabled->setFromAutomationPolicy($attach); $xlvask->config->automatic_order_creation_enabled->setFromAutomationPolicy($create); } private function formatPolicy(?array $state, array $runtime, array $budgets, array $calibrations, array $identity): array { $halted = $state !== null && (bool)($state['halted'] ?? false); $attachEnabled = !$halted && (bool)($state['attach_enabled'] ?? false) && $runtime['attachment_config_enabled']; $createEnabled = $attachEnabled && (bool)($state['create_enabled'] ?? false) && $runtime['creation_config_enabled']; $stage = $halted ? 'halted' : (string)($state['stage'] ?? 'off'); return [ 'policy_version' => (string)$identity['policy_version'], 'expected_version' => (int)($state['expected_version'] ?? 0), 'planner_identity_hash' => (string)$identity['identity_hash'], 'halted' => $halted, 'halt_reason' => $state['halt_reason'] ?? null, 'attach_halt_reason' => $state['attach_halt_reason'] ?? null, 'create_halt_reason' => $state['create_halt_reason'] ?? null, 'attach_enabled' => $attachEnabled, 'create_enabled' => $createEnabled, 'effective_stage' => $stage, 'runtime_kill_switches' => $runtime, 'calibration_segments' => array_keys($calibrations), ]; } private function recordPolicyEvent(string $eventType, int $actorId, array $details): void { global $db; $detailsJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($details)); if ($db->query( "INSERT INTO xlvask_automation_policy_events (event_type, actor_id, details_json) VALUES ('" . $db->escape_string($eventType) . "', {$actorId}, '{$detailsJson}')" ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('The XL Vask automation policy event could not be recorded.'); } } private static function uuidV4(): string { $data = random_bytes(16); $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); } }