diff --git a/wordpress/tests/durable/journal-cas-unit.php b/wordpress/tests/durable/journal-cas-unit.php index 9c695ca..aa7383a 100644 --- a/wordpress/tests/durable/journal-cas-unit.php +++ b/wordpress/tests/durable/journal-cas-unit.php @@ -235,4 +235,47 @@ function fresh_db(): Fake_Journal_Db { return new Fake_Journal_Db(); } eq( $db->read_row( 'plan-F', 31 )['state'], 'PENDING', 'F failed CAS wrote nothing' ); echo "#296 F caller failure contract: PASS ($assertions assertions)\n"; +// G. #297 applied_at evidence: strict calendar/clock validity, not shape only. +// The shape-only regex accepted month 19, February 31 and hour 24. The shared +// helper backs both worker reconciliation and verifier observe(); downstream +// NEEDS_REVIEW fail-closed behavior for NULL/zero-date rows is already proven +// by the jobs integration cases, which need a real database. +function stamp_row( $applied_at ) { + $row = array( 'applied_at' => $applied_at ); + if ( null === $applied_at ) { unset( $row['applied_at'] ); } + return $row; +} +foreach ( array( '2026-10-16 17:29:30', '2024-02-29 12:00:00', '2026-01-31 23:59:59', '2026-04-30 00:00:00', '2026-10-15 14:00:01' ) as $valid ) { + eq( J::has_valid_applied_at( stamp_row( $valid ) ), true, 'G accepts ' . $valid ); +} +foreach ( array( + 'missing key' => null, + 'empty string' => '', + 'zero-date' => '0000-00-00 00:00:00', + 'month 13' => '2026-13-01 00:00:00', + 'month 00' => '2026-00-10 00:00:00', + 'month 19 day 39 hour 29' => '2026-19-39 29:59:59', + 'Feb 29 non-leap' => '2023-02-29 12:00:00', + 'Feb 31' => '2026-02-31 12:00:00', + 'day 00' => '2026-01-00 12:00:00', + 'day 32' => '2026-01-32 12:00:00', + 'hour 24' => '2026-04-15 24:00:00', + 'hour 29' => '2026-10-16 29:00:00', + 'minute 60' => '2026-10-16 12:60:00', + 'second 60' => '2026-10-16 12:00:60', + 'trailing space' => '2026-10-16 17:29:30 ', + 'trailing char' => '2026-10-16 17:29:30x', + 'leading space' => ' 2026-10-16 17:29:30', + 'date only' => '2026-10-16', + 'time only' => '17:29:30', + 'slashes' => '2026/10/16 17:29:30', + 'iso T' => '2026-10-16T17:29:30', + 'dmy' => '16-10-2026 17:29:30', + 'garbage' => 'not-a-timestamp', +) as $label => $invalid ) { + eq( J::has_valid_applied_at( stamp_row( $invalid ) ), false, 'G rejects ' . $label ); +} +eq( J::has_valid_applied_at( array( 'applied_at' => null ) ), false, 'G rejects explicit null' ); +echo "#297 G applied_at calendar/clock validity: PASS ($assertions assertions)\n"; + echo "#296 journal compare-and-set unit matrix: PASS ($assertions assertions total)\n"; diff --git a/wordpress/tests/jobs/integration.php b/wordpress/tests/jobs/integration.php index 732918f..7ac3f2a 100644 --- a/wordpress/tests/jobs/integration.php +++ b/wordpress/tests/jobs/integration.php @@ -12,6 +12,10 @@ use WriteLeash\Price_Selection_Spec as Selection; use WriteLeash\Safety_Policy as Policy; use WriteLeash\Woo_Price_Planner as Planner; +use WriteLeash\Undo_Repository as URepo; +use WriteLeash\Undo_Worker as UWorker; +use WriteLeash\Undo_Item_State as UItem; +use WriteLeash\Undo_Schema as USchema; global $wpdb, $work, $assertions, $hook_log, $items_table, $jobs_table; $assertions = 0; @@ -512,6 +516,174 @@ function limits( int $items, int $seconds = 20 ): array { return array( 'max_ite marker( 'kill between checkpoints ' . $point . ': durable PENDING, resume once, one net price change' ); } +// --------------------------------------------------------------------------- +// #297 live-worker ambiguous commit: the mutator returns NEEDS_REVIEW while +// the worker stays alive, after a durable journal APPLIED. Journal truth plus +// binding is adopted with the journal applied_at; the Woo save never replays. +// --------------------------------------------------------------------------- +$f = job_fixture( array( 'changing' => 2 ) ); +$job_id = (int) $f['job']['id']; +$live_ids = array_values( $f['ids'] ); +$live_a = $live_ids[0]; +$live_b = $live_ids[1]; +approves( $f ); +Worker::queue_job( $job_id ); +$live = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'mutator_checkpoint' => 'AFTER_COMMIT_BEFORE_RESPONSE', 'mutator_fault' => 'throw' ) ); +$journal_a = journal_row( $f['plan']->data()['plan_id'], $live_a ); +$journal_b = journal_row( $f['plan']->data()['plan_id'], $live_b ); +eq( $journal_a['state'], 'APPLIED', 'live fault journal durable APPLIED A' ); +eq( $journal_b['state'], 'APPLIED', 'live fault journal durable APPLIED B' ); +ok( '' !== ( $journal_a['evidence'] ?? '' ), 'live fault journal evidence intact A' ); +$live_items = item_map( $job_id ); +eq( $live_items[ $live_a ]['state'], IState::APPLIED, 'live fault item adopted APPLIED A' ); +eq( $live_items[ $live_b ]['state'], IState::APPLIED, 'live fault item adopted APPLIED B' ); +eq( $live_items[ $live_a ]['reason'], 'DURABLE_RECONCILED', 'live fault typed adoption reason' ); +eq( $live_items[ $live_a ]['applied_at'], $journal_a['applied_at'], 'live fault applied_at is the journal applied_at' ); +eq( saves( $live_a ), 1, 'live fault no second save A' ); +eq( saves( $live_b ), 1, 'live fault no second save B' ); +eq( fresh_price( $live_a ), '80.00', 'live fault planned target A' ); +eq( $live['status'], JState::COMPLETED, 'live fault job completes without review' ); +assert_counts( $job_id, 'live-ambiguous-adopt' ); +marker( 'live ambiguous commit: bound APPLIED adopted with journal applied_at, zero replay' ); + +// A reconciled APPLIED item stays eligible for conditional Undo, while a +// newer external price on the sibling still conflicts instead of overwriting. +$p = wc_get_product( $live_b ); +$p->set_regular_price( '70.00' ); +$p->save(); +$live_external_saves = saves( $live_b ); +wp_set_current_user( 1 ); +$live_op = URepo::initiate( $job_id, 1 ); +$live_undo = UWorker::run( (int) $live_op['id'], limits( 5 ), true ); +global $wpdb; +$undo_a = URepo::read_item( $wpdb, $job_id, $live_a ); +$undo_b = URepo::read_item( $wpdb, $job_id, $live_b ); +eq( $undo_a['state'], UItem::UNDONE, 'reconciled item undo eligible and restored' ); +eq( Decimal::parse( fresh_price( $live_a ) ), '100', 'reconciled item restored to pre-apply price' ); +eq( $undo_b['state'], UItem::CONFLICT, 'newer external price still conflicts' ); +eq( fresh_price( $live_b ), '70.00', 'newer external price never overwritten' ); +eq( saves( $live_b ), $live_external_saves, 'conflicting undo performs zero restores' ); +marker( 'reconciled APPLIED feeds conditional Undo; newer price conflicts' ); +// The next cache profile reuses job IDs after mid-file table recreation while +// Undo tables persist: remove only this block's Undo operation/items, exactly +// like the migration fixture cleanup at the end of this file. +eq( $wpdb->delete( USchema::items_table( $wpdb ), array( 'undo_id' => (int) $live_op['id'], 'plan_id' => $f['plan']->data()['plan_id'] ) ), 2, 'cleanup this reconciliation fixture Undo items' ); +eq( $wpdb->delete( USchema::operations_table( $wpdb ), array( 'id' => (int) $live_op['id'], 'plan_id' => $f['plan']->data()['plan_id'] ) ), 1, 'cleanup this reconciliation fixture Undo operation' ); + +// --------------------------------------------------------------------------- +// #297 fault before commit: the rolled-back attempt is never adopted as +// APPLIED and never saves twice; an explicit retry still applies once. +// --------------------------------------------------------------------------- +$f = job_fixture( array( 'changing' => 1 ) ); +$job_id = (int) $f['job']['id']; +$id = $f['ids'][0]; +approves( $f ); +Worker::queue_job( $job_id ); +$pre = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'mutator_checkpoint' => 'AFTER_JOURNAL_BEFORE_COMMIT', 'mutator_fault' => 'throw' ) ); +eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'PENDING', 'pre-commit fault never durable APPLIED' ); +$pre_item = item_map( $job_id )[ $id ]; +eq( $pre_item['state'], IState::PENDING, 'pre-commit fault item stays retryable PENDING' ); +ok( IState::APPLIED !== $pre_item['state'], 'pre-commit fault never falsely adopted' ); +eq( saves( $id ), 1, 'pre-commit fault single rolled-back save' ); +eq( fresh_price( $id ), '100.00', 'pre-commit fault keeps the original price' ); +$wpdb->query( $wpdb->prepare( "UPDATE $items_table SET next_attempt_after=NULL WHERE job_id=%d AND product_id=%d", $job_id, $id ) ); +$retry = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'manual' => true ) ); +eq( $retry['status'], JState::COMPLETED, 'retry after pre-commit fault completes' ); +eq( saves( $id ), 2, 'retry writes exactly once more after a rolled-back save' ); +eq( fresh_price( $id ), '80.00', 'retry writes the planned target once' ); +eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'APPLIED', 'retry durable APPLIED' ); +marker( 'pre-commit fault: no false adoption, explicit retry applies once' ); + +// --------------------------------------------------------------------------- +// #297 journal binding mismatch under a live worker: APPLIED in the journal +// but corrupt material stays review, with zero new mutation. +// --------------------------------------------------------------------------- +$f = job_fixture( array( 'changing' => 1 ) ); +$job_id = (int) $f['job']['id']; +$id = $f['ids'][0]; +approves( $f ); +Worker::queue_job( $job_id ); +$clean = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ) ) ); +eq( $clean['status'], JState::COMPLETED, 'binding fixture applies clean' ); +eq( saves( $id ), 1, 'binding fixture one save' ); +// Corrupt the material the binding check covers, then stage a live worker +// attempt against the same row by returning its item to PENDING and its job +// to PAUSED in the DB (staging only; no state-machine transition is used). +$journal_table = Journal::table( $wpdb ); +eq( (int) $wpdb->update( $journal_table, array( 'target_price' => '79.99' ), array( 'plan_id' => $f['plan']->data()['plan_id'], 'product_id' => $id ) ), 1, 'binding mismatch staged' ); +eq( (int) $wpdb->update( $items_table, array( 'state' => IState::PENDING, 'claim_token' => '', 'claim_generation' => 0 ), array( 'job_id' => $job_id, 'product_id' => $id ) ), 1, 'live attempt staged' ); +eq( (int) $wpdb->update( $jobs_table, array( 'status' => JState::PAUSED ), array( 'id' => $job_id ) ), 1, 'staged job resumable' ); +$mis = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'manual' => true ) ); +$mis_item = item_map( $job_id )[ $id ]; +eq( $mis_item['state'], IState::NEEDS_REVIEW, 'binding mismatch stays review' ); +eq( saves( $id ), 1, 'binding mismatch zero new saves' ); +eq( fresh_price( $id ), '80.00', 'binding mismatch keeps the applied price' ); +eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'APPLIED', 'binding mismatch leaves the journal row alone' ); +marker( 'binding mismatch under live worker: review, zero mutation' ); + +// --------------------------------------------------------------------------- +// #297 commit() itself throws after the durable write took effect: the worker +// cannot know the outcome from the exception alone, so it must reconcile from +// the journal instead of blindly retrying the mutation. +// --------------------------------------------------------------------------- +$f = job_fixture( array( 'changing' => 1 ) ); +$job_id = (int) $f['job']['id']; +$id = $f['ids'][0]; +approves( $f ); +Worker::queue_job( $job_id ); +$amb_worker = start_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'commit_checkpoint' => 'AFTER_COMMIT_BEFORE_RETURN', 'commit_fault' => 'throw-ambiguous' ) ); +$amb = finish_worker( $amb_worker ); +$amb_barrier = file_get_contents( $amb_worker['spec']['barrier'] ); +ok( str_starts_with( (string) $amb_barrier, 'AFTER_COMMIT_BEFORE_RETURN:' ), 'commit fault fired after durable COMMIT' ); +$amb_journal = journal_row( $f['plan']->data()['plan_id'], $id ); +eq( $amb_journal['state'], 'APPLIED', 'thrown commit left the durable APPLIED row' ); +$amb_item = item_map( $job_id )[ $id ]; +eq( $amb_item['state'], IState::APPLIED, 'thrown commit reconciled to APPLIED' ); +eq( $amb_item['reason'], 'DURABLE_RECONCILED', 'thrown commit typed adoption reason' ); +eq( $amb_item['applied_at'], $amb_journal['applied_at'], 'thrown commit applied_at is the journal applied_at' ); +eq( saves( $id ), 1, 'thrown commit never re-saved' ); +eq( fresh_price( $id ), '80.00', 'thrown commit kept the planned target' ); +eq( $amb['status'], JState::COMPLETED, 'thrown commit job completes without review' ); +assert_counts( $job_id, 'commit-throws-ambiguous' ); +marker( 'commit() throws after durable write: journal reconciliation, zero replay' ); + +// --------------------------------------------------------------------------- +// #297 fail closed on a bound APPLIED row without timestamp evidence: a NULL +// applied_at (missing) and a zero-date applied_at (empty) both stay +// NEEDS_REVIEW with zero new mutation. Valid APPLIED rows still reconcile, +// as proven by every adoption case above. +// --------------------------------------------------------------------------- +foreach ( array( 'null' => null, 'zero-date' => '0000-00-00 00:00:00' ) as $variant => $stamp ) { + $f = job_fixture( array( 'changing' => 1 ) ); + $job_id = (int) $f['job']['id']; + $id = $f['ids'][0]; + approves( $f ); + Worker::queue_job( $job_id ); + $setup = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ) ) ); + eq( $setup['status'], JState::COMPLETED, 'timestamp fixture applies clean ' . $variant ); + eq( saves( $id ), 1, 'timestamp fixture one save ' . $variant ); + $journal_table = Journal::table( $wpdb ); + $sql_mode = null; + if ( 'zero-date' === $variant ) { + // Strict mode refuses the empty timestamp; relax this session only to + // stage the storable empty shape, then restore immediately. + $sql_mode = $wpdb->get_var( 'SELECT @@SESSION.sql_mode' ); + $wpdb->query( "SET SESSION sql_mode=''" ); + } + eq( (int) $wpdb->update( $journal_table, array( 'applied_at' => $stamp ), array( 'plan_id' => $f['plan']->data()['plan_id'], 'product_id' => $id ) ), 1, 'timestamp evidence removed ' . $variant ); + if ( 'zero-date' === $variant ) { $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode=%s', (string) $sql_mode ) ); } + eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'APPLIED', 'timestamp case keeps APPLIED state ' . $variant ); + eq( (int) $wpdb->update( $items_table, array( 'state' => IState::PENDING, 'claim_token' => '', 'claim_generation' => 0 ), array( 'job_id' => $job_id, 'product_id' => $id ) ), 1, 'live attempt staged ' . $variant ); + eq( (int) $wpdb->update( $jobs_table, array( 'status' => JState::PAUSED ), array( 'id' => $job_id ) ), 1, 'staged job resumable ' . $variant ); + $ts = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'manual' => true ) ); + $ts_item = item_map( $job_id )[ $id ]; + eq( $ts_item['state'], IState::NEEDS_REVIEW, 'missing timestamp stays review ' . $variant ); + eq( saves( $id ), 1, 'missing timestamp zero new saves ' . $variant ); + eq( fresh_price( $id ), '80.00', 'missing timestamp keeps the applied price ' . $variant ); + eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'APPLIED', 'missing timestamp leaves the journal row alone ' . $variant ); + marker( 'missing applied_at (' . $variant . '): fail closed to NEEDS_REVIEW, zero mutation' ); +} + // --------------------------------------------------------------------------- // Transient read refusal at the job boundary: durable PENDING, bounded retry. // The retry re-runs the frozen precondition, so a newer edit wins as CONFLICT. diff --git a/wordpress/tests/jobs/worker.php b/wordpress/tests/jobs/worker.php index c61b825..e4bf2f9 100644 --- a/wordpress/tests/jobs/worker.php +++ b/wordpress/tests/jobs/worker.php @@ -33,6 +33,7 @@ $barrier = (string) ( $spec['mutator_barrier'] ?? $spec['barrier'] ); $release = (string) ( $spec['mutator_release'] ?? $spec['release'] ); file_put_contents( $barrier, $point . ':' . (int) $GLOBALS['wpdb']->dbh->thread_id ); + if ( 'throw' === ( $spec['mutator_fault'] ?? '' ) ) { throw new RuntimeException( 'controlled-mutator-fault' ); } if ( 'wait' === ( $spec['mutator_fault'] ?? '' ) ) { $deadline = microtime( true ) + 30; while ( ! is_file( $release ) ) { @@ -42,6 +43,28 @@ } }, 10, 3 ); } +// #297 commit-level fault: the hook fires inside Price_Apply_Connection::commit() +// after the durable COMMIT succeeded but before ownership is released. A throw +// here models a lost acknowledgement with the write already durable; the +// worker cannot know the outcome from the exception alone. +// The commit transport is shared (claims, setup, mutations), so the fault +// arms only once the mutator reaches AFTER_JOURNAL_BEFORE_COMMIT: the next +// commit on this connection is then provably the item commit, never a claim. +if ( ! empty( $spec['commit_checkpoint'] ) ) { + add_action( 'writeleash_price_apply_checkpoint', static function ( $point ) use ( $spec ) { + if ( 'AFTER_JOURNAL_BEFORE_COMMIT' !== $point ) { return; } + // One-shot: the next transport commit is provably the item commit, so + // the fault must disarm itself to leave later claim/finish commits alone. + $fault = null; + $fault = static function ( $cpoint ) use ( $spec, &$fault ) { + if ( $cpoint !== $spec['commit_checkpoint'] ) { return; } + remove_action( 'writeleash_price_commit_checkpoint', $fault, 10 ); + file_put_contents( $spec['barrier'], $cpoint . ':' . (int) $GLOBALS['wpdb']->dbh->thread_id ); + if ( 'throw-ambiguous' === ( $spec['commit_fault'] ?? '' ) ) { throw new \WriteLeash\Price_Apply_Error( 'AMBIGUOUS_COMMIT' ); } + }; + add_action( 'writeleash_price_commit_checkpoint', $fault, 10, 2 ); + }, 10, 3 ); +} // Public Woo factory filter staging a transient read refusal for one product, // exactly as the #209 durable probe does; never a mutator/production mock. if ( ! empty( $spec['class_fault'] ) ) { diff --git a/wordpress/tests/undo/integration.php b/wordpress/tests/undo/integration.php index 8ae0cf9..a3317eb 100644 --- a/wordpress/tests/undo/integration.php +++ b/wordpress/tests/undo/integration.php @@ -508,6 +508,47 @@ function expire_undo_lease( int $undo_id ): void { $manager_role->add_cap( 'edit_products' ); marker( 'permission revocation: zero mutation, typed PERMISSION_DENIED' ); +// --------------------------------------------------------------------------- +// #297 Undo executor refresh with a warm object cache: a user-level capability +// revoke staged below the cache must still deny. The role-level revoke above +// is visible through the role-option eviction the mutator already did; this +// case targets the executor user cache that only clean_user_cache() drops. +// --------------------------------------------------------------------------- +$stale_actor = wp_insert_user( array( 'user_login' => 'wl110-stale-' . wp_generate_uuid4(), 'user_pass' => wp_generate_password(), 'role' => 'shop_manager' ) ); +$st = apply_fixture( array( 'changing' => 1, 'actor' => (int) $stale_actor ) ); +$st_job = (int) $st['job']['id']; +$st_id = $st['ids'][0]; +$st_op = start_undo( $st_job, (int) $stale_actor ); +$st_undo = (int) $st_op['id']; +// Warm the executor's cached user/capability state while still authorized. +$stale_probe = new WP_User( (int) $stale_actor ); +eq( user_can( $stale_probe, 'edit_products' ), true, 'stale-revoke fixture authorized before revoke' ); +$persistent = wp_using_ext_object_cache(); +echo '#110 undo revoke cache profile: persistent=' . ( $persistent ? 'yes' : 'no' ) . "\n"; +// External revoke straight in the database: the object cache keeps serving the +// authorized caps until the mutator refreshes them. Never use set_role() here; +// the metadata API would invalidate the cache and hide the staleness. +$cap_key = $wpdb->get_blog_prefix() . 'capabilities'; +eq( (int) $wpdb->update( $wpdb->usermeta, array( 'meta_value' => serialize( array( 'subscriber' => true ) ) ), array( 'user_id' => (int) $stale_actor, 'meta_key' => $cap_key ) ), 1, 'stale-revoke staged below the cache' ); +// The smoking gun, read through a fresh user object: stale authorized caps. +$stale_view = new WP_User( (int) $stale_actor ); +eq( user_can( $stale_view, 'edit_products' ), true, 'stale authorized caps served from cache' ); +if ( $persistent ) { + // Cross-process proof that the persistent cache holds the stale grant: a + // fresh process with a cold runtime cache still reads authorized. + $stale_probe_cmd = 'echo (int) user_can( new WP_User( ' . (int) $stale_actor . " ), 'edit_products' );"; + $stale_probe_out = shell_exec( 'wp --path=' . escapeshellarg( ABSPATH ) . ' eval ' . escapeshellarg( $stale_probe_cmd ) ); + eq( trim( (string) $stale_probe_out ), '1', 'persistent cache serves stale grant cross-process' ); +} +$stale_saves = saves( $st_id ); +$stale_run = run_worker( array( 'undo_id' => $st_undo, 'mode' => 'undo', 'limits' => limits( 5 ) ) ); +eq( $stale_run['stop'], 'PERMISSION_REVOKED', 'stale-cache revoke typed pause' ); +eq( undo_row( $st_job, $st_id )['state'], UItem::FAILED, 'stale-cache revoke item failed without retry' ); +eq( undo_row( $st_job, $st_id )['reason'], 'PERMISSION_DENIED', 'stale-cache revoke typed reason' ); +eq( fresh_price( $st_id ), '80.00', 'stale-cache revoke zero mutation' ); +eq( saves( $st_id ), $stale_saves, 'stale-cache revoke zero restores' ); +marker( 'undo executor refresh: user-level revoke under warm cache denied, zero restores' ); + $actor2 = wp_insert_user( array( 'user_login' => 'wl110-gone-' . wp_generate_uuid4(), 'user_pass' => wp_generate_password(), 'role' => 'shop_manager' ) ); $gone = apply_fixture( array( 'changing' => 1, 'actor' => (int) $actor2 ) ); $gone_job = (int) $gone['job']['id']; diff --git a/wordpress/writeleash/includes/free/class-job-worker.php b/wordpress/writeleash/includes/free/class-job-worker.php index 1be9635..5b0e8f6 100644 --- a/wordpress/writeleash/includes/free/class-job-worker.php +++ b/wordpress/writeleash/includes/free/class-job-worker.php @@ -178,6 +178,33 @@ private static function process( int $job_id, string $owner, int $generation, Ch return array( 'processed' => $processed, 'stop' => $stop, 'pause' => $pause, 'review' => $review ); } + /** + * #297 live-worker reconciliation: a live mutator result of NEEDS_REVIEW + * (ambiguous commit, lost acknowledgement) may still be a durable APPLIED. + * The journal row plus binding is the oracle; the exception code is not. + * Adopt only a bound APPLIED row under this worker's own claim token, so + * the write stays APPLYING -> APPLIED with the journal's applied_at. A + * lost claim token fails closed to the next generation's reconcile(): + * no forced outcome, no second mutation. Anything weaker than a bound + * APPLIED returns false and the caller records NEEDS_REVIEW. + */ + private static function reconcile_durable_apply( Change_Plan $plan, array $item, string $token, int $product_id ): bool { + try { $observer = Price_Cache_Verifier::observer(); } + catch ( \Throwable $error ) { return false; } + try { + $row = Price_Apply_Journal::read( $observer, $plan->data()['plan_id'], $product_id ); + if ( ! $row || 'APPLIED' !== $row['state'] ) { return false; } + try { Price_Apply_Journal::assert_binding( $row, $plan, $product_id ); } + catch ( \Throwable $error ) { return false; } + // applied_at is stamped atomically with the APPLIED transition, and + // the observer rejects an APPLIED row without it. A bound APPLIED + // row lacking a valid timestamp is inconsistent evidence: fail + // closed to NEEDS_REVIEW. Never invent a timestamp here. + if ( ! Price_Apply_Journal::has_valid_applied_at( $row ) ) { return false; } + return Job_Repository::record_item( $item, $token, Job_Item_State::APPLIED, 'DURABLE_RECONCILED', (string) $row['applied_at'] ); + } catch ( \Throwable $error ) { return false; } + finally { $observer->close(); } + } /** One #108 item transaction plus durable outcome recording. */ private static function attempt_item( int $job_id, string $owner, int $generation, Change_Plan $plan, array $item, string $token ): array { try { Job_Repository::assert_item_material( $plan, $item ); } @@ -253,6 +280,13 @@ private static function attempt_item( int $job_id, string $owner, int $generatio Job_Repository::record_item( $item, $token, Job_Item_State::PENDING, 'FAILED', null, true ); return array( 'pause' => null, 'review' => false ); } + // #297: the mutator may report NEEDS_REVIEW after a durable journal + // commit (ambiguous COMMIT, lost acknowledgement). Re-read the journal + // before stranding the item: a bound APPLIED row is adopted with its + // own applied_at and no second save. Never inferred from the code. + if ( self::reconcile_durable_apply( $plan, $item, $token, $product_id ) ) { + return array( 'pause' => null, 'review' => false ); + } Job_Repository::record_item( $item, $token, Job_Item_State::NEEDS_REVIEW, $reason ); return array( 'pause' => 'ITEM_NEEDS_REVIEW', 'review' => true ); } diff --git a/wordpress/writeleash/includes/free/class-price-apply-connection.php b/wordpress/writeleash/includes/free/class-price-apply-connection.php index 5456ac6..55f2ea7 100644 --- a/wordpress/writeleash/includes/free/class-price-apply-connection.php +++ b/wordpress/writeleash/includes/free/class-price-apply-connection.php @@ -78,6 +78,13 @@ public function query( $sql ) { public function commit(): void { $this->assert_owned(); try { $this->raw( 'COMMIT' ); } catch ( \Throwable $e ) { throw new Price_Apply_Error( 'AMBIGUOUS_COMMIT' ); } + // #297 test seam: fires after the durable COMMIT succeeded but before + // ownership is released, so a fault here models a lost acknowledgement + // with the write already durable. The transport is shared by claims, + // setup and mutations, so listeners must arm narrowly (e.g. only after + // the mutator's pre-commit checkpoint). No listener in production; the + // mutator treats any throw as an unknown outcome, never as success. + do_action( 'writeleash_price_commit_checkpoint', 'AFTER_COMMIT_BEFORE_RETURN', $this->connection_id ); $this->owned = false; } public function owns_attempt(): bool { return $this->owned; } diff --git a/wordpress/writeleash/includes/free/class-price-apply-journal.php b/wordpress/writeleash/includes/free/class-price-apply-journal.php index 534f2a6..2f24ad8 100644 --- a/wordpress/writeleash/includes/free/class-price-apply-journal.php +++ b/wordpress/writeleash/includes/free/class-price-apply-journal.php @@ -197,6 +197,21 @@ public static function read( \wpdb $db, string $plan_id, int $id, bool $lock = f $row = $db->get_row( $db->prepare( "SELECT * FROM %i WHERE plan_id=%s AND product_id=%d" . ( $lock ? ' FOR UPDATE' : '' ), $table, $plan_id, $id ), ARRAY_A ); return $row ?: null; } + /** + * #297 timestamp evidence: applied_at is stamped atomically with the + * APPLIED transition, so only a real timestamp counts. NULL, empty and + * zero-date values are inconsistent evidence and must fail closed. The + * shape match alone cannot tell month lengths or leap years apart, so the + * captured date also goes through checkdate(), which validates the real + * calendar without normalizing or touching timezones. Hour 24+ passes the + * shape but is not a valid clock reading. + */ + public static function has_valid_applied_at( array $row ): bool { + $stamp = (string) ( $row['applied_at'] ?? '' ); + if ( 1 !== preg_match( '/\A([1-9][0-9]{3})-([01][0-9])-([0-3][0-9]) ([0-2][0-9]):([0-5][0-9]):([0-5][0-9])\z/', $stamp, $m ) ) { return false; } + if ( (int) $m[4] > 23 ) { return false; } + return checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ); + } /** * #296 compare-and-set: the row moves only from the state the caller * observed. The expected state is part of the UPDATE predicate, so the diff --git a/wordpress/writeleash/includes/free/class-price-cache-verifier.php b/wordpress/writeleash/includes/free/class-price-cache-verifier.php index 0845256..19b28c9 100644 --- a/wordpress/writeleash/includes/free/class-price-cache-verifier.php +++ b/wordpress/writeleash/includes/free/class-price-cache-verifier.php @@ -219,7 +219,7 @@ public static function observe( Change_Plan $plan, int $id ): array { try { $row = Price_Apply_Journal::read( $db, $plan->data()['plan_id'], $id ); $item = $plan->item( $id )->data(); - if ( ! $row || 'APPLIED' !== $row['state'] || ! $row['applied_at'] || ! $row['attempt_id'] ) { throw new Price_Apply_Error( 'JOURNAL_MISMATCH' ); } + if ( ! $row || 'APPLIED' !== $row['state'] || ! Price_Apply_Journal::has_valid_applied_at( $row ) || ! $row['attempt_id'] ) { throw new Price_Apply_Error( 'JOURNAL_MISMATCH' ); } Price_Apply_Journal::assert_binding( $row, $plan, $id ); $evidence = json_decode( $row['evidence'], true ); if ( ! is_array( $evidence ) || ( $evidence['plan_id'] ?? '' ) !== $row['plan_id'] || ( $evidence['plan_schema_version'] ?? 0 ) !== (int) $row['plan_schema_version'] || ( $evidence['plan_hash_version'] ?? '' ) !== $row['plan_hash_version'] || ( $evidence['attempt_id'] ?? '' ) !== $row['attempt_id'] || ( $evidence['plan_hash'] ?? '' ) !== $row['plan_hash'] || ( $evidence['target'] ?? '' ) !== $row['target_price'] || ( $evidence['product_id'] ?? 0 ) !== $id || ( $evidence['connection_id'] ?? 0 ) < 1 ) { throw new Price_Apply_Error( 'JOURNAL_MISMATCH' ); } diff --git a/wordpress/writeleash/includes/free/class-woo-undo-mutator.php b/wordpress/writeleash/includes/free/class-woo-undo-mutator.php index b3c3ead..559125f 100644 --- a/wordpress/writeleash/includes/free/class-woo-undo-mutator.php +++ b/wordpress/writeleash/includes/free/class-woo-undo-mutator.php @@ -41,6 +41,11 @@ private static function schema( \wpdb $db ): void { private static function authorize( int $product_id ): void { $user_id = get_current_user_id(); if ( $user_id < 1 ) { throw new Price_Apply_Error( 'PERMISSION_DENIED' ); } + // Same refresh as the Apply path, after the existing row locks and + // before capabilities are evaluated: role definitions and the current + // executor's cached user state are reloaded. Principal unchanged. + wp_roles()->for_site( get_current_blog_id() ); + clean_user_cache( $user_id ); $user = new \WP_User( $user_id ); if ( ! user_can( $user, 'manage_woocommerce' ) || ! user_can( $user, 'edit_products' ) || ! user_can( $user, 'edit_post', $product_id ) ) { throw new Price_Apply_Error( 'PERMISSION_DENIED' ); } }