diff --git a/docs/core-system/pending-actions.md b/docs/core-system/pending-actions.md index a6753794a..2fbc6be76 100644 --- a/docs/core-system/pending-actions.md +++ b/docs/core-system/pending-actions.md @@ -2,6 +2,8 @@ Data Machine implements the Agents API pending-action approval storage contract with WordPress-backed durable storage. +Pending actions are approval and audit records. Normal runtime requires the durable `datamachine_pending_actions` table; if database access is unavailable, store/resolution operations fail closed and emit `datamachine_pending_action_store_unavailable`. The transient store path is reserved for pure-PHP smoke tests or explicit pre-table boot by defining `DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK`. + ## Boundary - Agents API owns generic approval vocabulary and contracts. diff --git a/inc/Engine/AI/Actions/PendingActionStore.php b/inc/Engine/AI/Actions/PendingActionStore.php index 986240aed..88fd84f5e 100644 --- a/inc/Engine/AI/Actions/PendingActionStore.php +++ b/inc/Engine/AI/Actions/PendingActionStore.php @@ -26,8 +26,9 @@ * 'context' => array( ... ), // free-form (session_id, bridge_app, etc.) * ) * - * Uses durable WordPress database storage in normal runtime. Pure-PHP smoke - * tests and pre-table boot can still fall back to transient storage. + * Uses durable WordPress database storage in normal runtime. Pure-PHP smoke + * tests and explicitly opted-in pre-table boot can fall back to transient + * storage by defining DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK. * * @package DataMachine\Engine\AI\Actions * @since 0.72.0 @@ -63,7 +64,7 @@ class PendingActionStore { /** * Transient fallback key prefix for pure-PHP smoke tests and pre-table boot. */ - private const TRANSIENT_PREFIX = 'dm_pa_'; + private const TRANSIENT_PREFIX = 'datamachine_pending_action_'; /** * Agents API store contract singleton. @@ -202,6 +203,11 @@ public static function store( string $action_id, array $payload ): bool { $payload['context'] = $context; if ( ! self::has_database() ) { + if ( ! self::allows_transient_fallback() ) { + self::warn_database_unavailable( 'store' ); + return false; + } + $payload['created_at'] = time(); $payload['expires_at'] = time() + self::resolve_ttl( $payload ); $payload['action_id'] = $action_id; @@ -269,6 +275,11 @@ public static function store( string $action_id, array $payload ): bool { */ public static function get( string $action_id, bool $include_resolved = false ): ?array { if ( ! self::has_database() ) { + if ( ! self::allows_transient_fallback() ) { + self::warn_database_unavailable( 'get' ); + return null; + } + $data = get_transient( self::TRANSIENT_PREFIX . $action_id ); return is_array( $data ) ? $data : null; } @@ -311,6 +322,11 @@ public static function get( string $action_id, bool $include_resolved = false ): */ public static function delete( string $action_id ): bool { if ( ! self::has_database() ) { + if ( ! self::allows_transient_fallback() ) { + self::warn_database_unavailable( 'delete' ); + return false; + } + return delete_transient( self::TRANSIENT_PREFIX . $action_id ); } @@ -330,6 +346,11 @@ public static function record_resolution( string $action_id, string $decision, $ global $wpdb; if ( ! self::has_database() ) { + if ( ! self::allows_transient_fallback() ) { + self::warn_database_unavailable( 'record_resolution' ); + return false; + } + $payload = get_transient( self::TRANSIENT_PREFIX . $action_id ); $action = is_array( $payload ) ? self::action_from_payload( $payload ) : null; $deleted = delete_transient( self::TRANSIENT_PREFIX . $action_id ); @@ -834,6 +855,32 @@ private static function has_database(): bool { return is_object( $wpdb ) && method_exists( $wpdb, 'replace' ) && method_exists( $wpdb, 'get_row' ); } + /** + * Check whether the non-durable transient fallback has been explicitly enabled. + */ + private static function allows_transient_fallback(): bool { + return defined( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ) + && true === DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK; + } + + /** + * Report attempts to use pending actions without durable storage. + */ + private static function warn_database_unavailable( string $operation ): void { + $message = sprintf( + 'PendingActionStore::%s() requires the durable pending-actions table; define DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK only for pure-PHP smoke tests or explicit pre-table boot.', + $operation + ); + + if ( function_exists( '_doing_it_wrong' ) ) { + _doing_it_wrong( esc_html( __CLASS__ . '::' . $operation ), esc_html( $message ), '1.0.0' ); + } + + if ( function_exists( 'do_action' ) ) { + do_action( 'datamachine_pending_action_store_unavailable', $operation, $message ); + } + } + /** * Normalize mixed timestamp input to a Unix timestamp. */ diff --git a/tests/pending-action-resolver-contract-smoke.php b/tests/pending-action-resolver-contract-smoke.php index 586c69958..052633d35 100644 --- a/tests/pending-action-resolver-contract-smoke.php +++ b/tests/pending-action-resolver-contract-smoke.php @@ -11,6 +11,10 @@ define( 'ABSPATH', dirname( __DIR__ ) . '/' ); } +if ( ! defined( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ) ) { + define( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK', true ); +} + $GLOBALS['__resolver_filters'] = array(); $GLOBALS['__resolver_transients'] = array(); diff --git a/tests/pending-action-store-durable-required-smoke.php b/tests/pending-action-store-durable-required-smoke.php new file mode 100644 index 000000000..d81197025 --- /dev/null +++ b/tests/pending-action-store-durable-required-smoke.php @@ -0,0 +1,130 @@ + $hook_name, + 'args' => $args, + ); + } +} + +if ( ! function_exists( '_doing_it_wrong' ) ) { + function _doing_it_wrong( string $function_name, string $message, string $version ): void { + $GLOBALS['__pending_action_unavailable_events'][] = array( + 'hook' => '_doing_it_wrong', + 'args' => array( $function_name, $message, $version ), + ); + } +} + +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( string $text ): string { + return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); + } +} + +if ( ! function_exists( 'set_transient' ) ) { + function set_transient( string $_key, $value, int $_expiration = 0 ): bool { + unset( $value ); + ++$GLOBALS['__pending_action_transient_writes']; + return true; + } +} + +if ( ! function_exists( 'get_transient' ) ) { + function get_transient( string $_key ) { + return false; + } +} + +if ( ! function_exists( 'delete_transient' ) ) { + function delete_transient( string $_key ): bool { + return true; + } +} + +require_once dirname( __DIR__ ) . '/vendor/automattic/agents-api/agents-api.php'; +require_once dirname( __DIR__ ) . '/inc/Core/Workspace/WordPressWorkspaceScope.php'; +require_once dirname( __DIR__ ) . '/inc/Engine/AI/Actions/PendingActionObservers.php'; +require_once dirname( __DIR__ ) . '/inc/Engine/AI/Actions/PendingActionStore.php'; + +use DataMachine\Engine\AI\Actions\PendingActionStore; + +$failures = array(); +$passes = 0; + +echo "pending-action-store-durable-required-smoke\n"; + +function datamachine_pending_store_assert( bool $condition, string $message, array &$failures, int &$passes ): void { + if ( $condition ) { + ++$passes; + echo "PASS: {$message}\n"; + return; + } + + $failures[] = $message; + echo "FAIL: {$message}\n"; +} + +$payload = array( + 'kind' => 'durable_required', + 'summary' => 'Durable storage required.', + 'apply_input' => array( 'target' => 'approval' ), +); + +datamachine_pending_store_assert( false === PendingActionStore::store( 'act_durable_required', $payload ), 'store fails closed without durable storage', $failures, $passes ); +datamachine_pending_store_assert( 0 === $GLOBALS['__pending_action_transient_writes'], 'store does not silently write transient approvals', $failures, $passes ); +datamachine_pending_store_assert( null === PendingActionStore::get( 'act_durable_required' ), 'get returns no action without durable storage', $failures, $passes ); +datamachine_pending_store_assert( false === PendingActionStore::record_resolution( 'act_durable_required', 'accepted' ), 'resolution fails closed without durable storage', $failures, $passes ); +datamachine_pending_store_assert( false === PendingActionStore::delete( 'act_durable_required' ), 'delete fails closed without durable storage', $failures, $passes ); +datamachine_pending_store_assert( array() === PendingActionStore::list(), 'list remains empty without durable storage', $failures, $passes ); +datamachine_pending_store_assert( 0 === PendingActionStore::summary()['total'], 'summary remains empty without durable storage', $failures, $passes ); +datamachine_pending_store_assert( 0 === PendingActionStore::expire_due_actions(), 'expiration does not mutate without durable storage', $failures, $passes ); + +$event_hooks = array_map( static fn( array $event ): string => (string) ( $event['hook'] ?? '' ), $GLOBALS['__pending_action_unavailable_events'] ); +datamachine_pending_store_assert( in_array( '_doing_it_wrong', $event_hooks, true ), 'missing durable storage emits a developer warning', $failures, $passes ); +datamachine_pending_store_assert( in_array( 'datamachine_pending_action_store_unavailable', $event_hooks, true ), 'missing durable storage emits an operator diagnostic action', $failures, $passes ); + +if ( ! empty( $failures ) ) { + echo "\nFailures:\n"; + foreach ( $failures as $failure ) { + echo '- ' . $failure . "\n"; + } + exit( 1 ); +} + +echo "\nPending-action durable-required smoke passed ({$passes} assertions).\n"; diff --git a/tests/pending-actions-agents-api-contract-smoke.php b/tests/pending-actions-agents-api-contract-smoke.php index b1168ebe3..ba43245c4 100644 --- a/tests/pending-actions-agents-api-contract-smoke.php +++ b/tests/pending-actions-agents-api-contract-smoke.php @@ -16,6 +16,10 @@ echo "pending-actions-agents-api-contract-smoke\n"; +if ( ! defined( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ) ) { + define( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK', true ); +} + function datamachine_pending_actions_assert( bool $condition, string $message, array &$failures, int &$passes ): void { if ( $condition ) { ++$passes; @@ -137,7 +141,10 @@ function datamachine_pending_actions_source( string $path ): string { datamachine_pending_actions_assert( str_contains( $signer_source, 'hash_hmac' ) && str_contains( $signer_source, 'datamachine_pending_action_resolution_secret' ), 'signed pending-action resolution uses a stored HMAC secret', $failures, $passes ); datamachine_pending_actions_assert( str_contains( $runtime_source, 'datamachine_migrate_pending_actions_table' ), 'upgrade path creates pending-action table on deployed installs', $failures, $passes ); -echo "\n[5] Store contract adapter preserves transient fallback behavior:\n"; +echo "\n[5] Store contract adapter uses the explicit test transient fallback seam:\n"; + +datamachine_pending_actions_assert( str_contains( $store_source, 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ), 'transient fallback requires an explicit opt-in constant', $failures, $passes ); +datamachine_pending_actions_assert( str_contains( $store_source, 'warn_database_unavailable' ), 'missing durable storage reports a clear unavailable-store warning', $failures, $passes ); if ( ! defined( 'ABSPATH' ) ) { define( 'ABSPATH', dirname( __DIR__ ) . '/' ); diff --git a/tests/signed-pending-action-resolution-smoke.php b/tests/signed-pending-action-resolution-smoke.php index fe1eef2da..1c98d7316 100644 --- a/tests/signed-pending-action-resolution-smoke.php +++ b/tests/signed-pending-action-resolution-smoke.php @@ -11,6 +11,10 @@ define( 'ABSPATH', dirname( __DIR__ ) . '/' ); } +if ( ! defined( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ) ) { + define( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK', true ); +} + $GLOBALS['__signed_filters'] = array(); $GLOBALS['__signed_transients'] = array(); $GLOBALS['__signed_options'] = array();