Files
api/services/nginx/app/classes/xlvask_automation_policy_service.php
T

1021 lines
50 KiB
PHP

<?php
namespace classes;
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
use Exception;
use Throwable;
final class xlvask_automation_control_stop extends Exception
{
public function __construct(public readonly string $reasonCode, string $message)
{
parent::__construct($message);
}
}
/**
* Server-authoritative activation, halt, soak and rolling-budget policy for XL Vask automation.
*
* Module configuration remains an immediate kill switch. It is deliberately not sufficient to
* enable automatic actions: an exact policy transition must also have been previewed and applied.
*/
class xlvask_automation_policy_service
{
public const ATTACH_DAILY_CAP = 100;
public const ATTACH_PER_HALL_DAILY_CAP = 10;
public const CREATE_DAILY_CAP = 20;
public const CREATE_PER_HALL_DAILY_CAP = 3;
public const LINK_SOAK_TARGET = 200;
public const CREATE_SOAK_TARGET = 50;
private const PREVIEW_TTL_SECONDS = 900;
private const POLICY_ROW_ID = 1;
public const STAGES = [
'off', 'advisory', 'ai_attach_canary', 'ai_attach_verified',
'ai_create_canary', 'verified_capped', 'halted',
];
public function capabilitiesReadOnly(?string $dateFrom = null, ?string $dateTo = null, array $hallIds = []): array
{
$readiness = $this->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));
}
}