From 3af16c9694d39af6f63261bc6be44874c3c3c4fe Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 22 Oct 2026 07:52:06 +0000 Subject: [PATCH 1/9] Retention mechanics: explicit closure, per-version evidence deadlines, narrow holds, deletion tombstones Findings are cleared twelve months after closure. Signed authorization evidence is deleted at a retain_until fixed at closure: six years after closure, except r2 and earlier acceptances, which keep their promised three years from acceptance (never less than three, whatever the configuration). A job that can still run is never closed or deleted. An administrator can place a narrow preservation hold on one job (stated purpose, mandatory expiry, recorded setter); a damaged hold preserves. Deleting a job first writes an identifier-only tombstone; a restored backup's rows are deleted again on the next maintenance run. Schema 6. Refs CompleteTech-LLC/complete.tech#17 (gap 6, mechanics). Co-Authored-By: Claude Code --- .github/workflows/tests.yml | 2 + docs/WORDPRESS.md | 40 ++- integrations/wordpress/ct-scan.php | 267 +++++++++++++++++-- integrations/wordpress/tests/integration.php | 2 +- integrations/wordpress/tests/retention.php | 175 ++++++++++++ 5 files changed, 452 insertions(+), 34 deletions(-) create mode 100644 integrations/wordpress/tests/retention.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1fac82a..409ada3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,8 @@ jobs: token=$(printf '%s' "$fixture" | jq -r .token) docker run --rm --network php2shell-ci_fixture -v "$PWD:/app:ro" -w /app -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/app -e CT_FIXTURE_TOKEN="$token" python:3.12-slim-bookworm@sha256:6a0e06f5ba0b373d051aabd3fffb9b873f27f9a2a3a14bcbe37dee6f0e3bbc70 python deployment/test-http-worker.py docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-http-fixture.php cleanup + - name: Test closure, retention periods, holds and deletion tombstones + run: docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/retention.php - name: Test target control (challenge limits, manual review, blocked outcome) and its HTTP boundary run: | docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/control.php diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 326c282..79ec432 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -425,7 +425,39 @@ site files, restore the scoped backups; existing accepted contracts and queue evidence must remain available. A started job with a missing receipt needs operator investigation, not a blind retry. -Retention follows the accepted policy: scan results twelve months, and request -and acceptance records three years. Maintenance must preserve current work and -must not delete unrelated WordPress content. Authentication replay records and -bounded controller logs have shorter operational lifetimes. +Retention follows the owner's decisions of 2026-10-22, implemented in +maintenance (which runs on every worker claim and on the admin queue page): + +- **Closure is explicit.** The first time a job is seen in a terminal state + (`completed`, `failed`, `failed_unknown`, `cancelled`, `expired`, `blocked`) + it is stamped `closed_at`; an accepted request that was never confirmed or + never showed target control closes when it expires. A job that can still run + is never closed or deleted, however old its acceptance. +- **Findings** (`result_json`, manual remainder notes): cleared twelve months + after closure. The signed evidence stays. +- **Signed authorization evidence** (the job row and its linked private request): + deleted at `retain_until`, fixed once at closure: **six years after closure**, + except that r2 and earlier acceptances keep their promised **three years from + acceptance** (config `legacy_retention_years`). Never less than three years, + whatever the configuration. The archive (issue 11) can read `retain_until` per + accepted version. +- **Narrow preservation hold** (admin queue): one job, a stated purpose of 20 to + 190 characters, a mandatory expiry of at most 730 days, recorded with who set + it and when. While it stands nothing about that job is cleared or deleted; an + expired or released hold lets retention proceed, and a damaged hold (purpose + without an expiry) preserves rather than deletes. +- **Tombstones.** Deleting a job writes an identifier-only tombstone (job id, + request id, scope hash, creation time, time and reason; no personal or + customer data) before the deletion. If a restored backup brings the rows back, + the next maintenance run deletes them again, and a restored private request is + deleted only if its saved job id is the tombstoned one. Tombstones are kept 120 + days, longer than the 90-day backup rotation, then purged. +- Deletion scrubs the contact fields first, then deletes the private request and + the job row; if any step fails the record stays (and is retried) rather than + being half-deleted. + +Not covered here: backup rotation itself, the contract archive's historical Git +objects and clones (issue 11), unsigned caller leads (there is no lead record in +this plugin; the current form saves nothing without a signature), and deletion +from mail systems. Authentication replay records and bounded controller logs +have shorter operational lifetimes. diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 5680c70..4230c1d 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -17,7 +17,7 @@ } if ( ! defined( 'CT_SCAN_SCHEMA_VERSION' ) ) { - define( 'CT_SCAN_SCHEMA_VERSION', '5' ); + define( 'CT_SCAN_SCHEMA_VERSION', '6' ); } /** @@ -87,6 +87,12 @@ function ct_scan_config() { // Terms versions whose jobs must prove control of the exact target (DNS TXT, HTTPS file, // or a recorded manual authority review) before any scan can be claimed. 'target_control_versions' => array(), + // Owner decisions (2026-10-22): findings 12 months; signed authorization evidence 6 years + // after request closure, except r2 and earlier acceptances, which keep 3 years from acceptance. + 'evidence_retention_years' => 6, + 'legacy_retention_years' => array( '2026-10-21-r2' => 3 ), + // How long a deletion tombstone is kept: longer than the longest backup rotation (90 days). + 'tombstone_retention_days' => 120, ); $path = defined( 'CT_SCAN_PRIVATE_CONFIG' ) @@ -126,6 +132,11 @@ function ct_scan_jobs_table() { return $wpdb->prefix . 'ct_scan_jobs'; } +function ct_scan_tombstones_table() { + global $wpdb; + return $wpdb->prefix . 'ct_scan_tombstones'; +} + function ct_scan_nonces_table() { global $wpdb; return $wpdb->prefix . 'ct_scan_nonces'; @@ -198,15 +209,37 @@ function ct_scan_maybe_install_schema() { control_verified_at datetime NULL, control_valid_until datetime NULL, control_evidence text NULL, + closed_at datetime NULL, + retain_until datetime NULL, + hold_reason varchar(190) NULL, + hold_until datetime NULL, + hold_set_by bigint(20) unsigned NULL, + hold_set_at datetime NULL, PRIMARY KEY (job_id), UNIQUE KEY request_id (request_id), KEY status_created (status,created_at), KEY scope_created (scope_hash,created_at), KEY authorization_expires_at (authorization_expires_at), KEY confirmation_token_hash (confirmation_token_hash), - KEY stop_token_hash (stop_token_hash) + KEY stop_token_hash (stop_token_hash), + KEY retain_until (retain_until) ) {$charset};"; dbDelta( $sql ); + // Deletion tombstones: identifiers only, no personal data. A restored backup cannot resurrect a + // deleted record because maintenance deletes anything that matches a tombstone again. + $tombstones = ct_scan_tombstones_table(); + dbDelta( "CREATE TABLE {$tombstones} ( + tombstone_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + job_id bigint(20) unsigned NOT NULL, + request_id bigint(20) unsigned NOT NULL, + scope_hash char(64) NOT NULL, + created_at datetime NOT NULL, + deleted_at datetime NOT NULL, + reason varchar(32) NOT NULL, + PRIMARY KEY (tombstone_id), + UNIQUE KEY identity (job_id,request_id,scope_hash,created_at), + KEY deleted_at (deleted_at) + ) {$charset};" ); $sql = "CREATE TABLE {$nonces} ( nonce_hash char(64) NOT NULL, expires_at datetime NOT NULL, @@ -215,8 +248,9 @@ function ct_scan_maybe_install_schema() { ) {$charset};"; dbDelta( $sql ); $required = array( - $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_attempts', 'control_last_attempt_ms', 'control_lease_hash', 'control_verified_at', 'control_valid_until', 'control_evidence' ), + $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_attempts', 'control_last_attempt_ms', 'control_lease_hash', 'control_verified_at', 'control_valid_until', 'control_evidence', 'closed_at', 'retain_until', 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at' ), $nonces => array( 'nonce_hash', 'expires_at' ), + $tombstones => array( 'tombstone_id', 'job_id', 'request_id', 'scope_hash', 'created_at', 'deleted_at', 'reason' ), ); foreach ( $required as $table => $columns ) { $found = $wpdb->get_col( "SHOW COLUMNS FROM {$table}", 0 ); @@ -729,38 +763,202 @@ function ct_scan_maintenance_locked() { $nonce_table = ct_scan_nonces_table(); $wpdb->query( $wpdb->prepare( "DELETE FROM {$nonce_table} WHERE expires_at < %s", gmdate( 'Y-m-d H:i:s', time() - 600 ) ) ); - $result_cutoff = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-12 months' )->format( 'Y-m-d H:i:s' ); + ct_scan_retention_locked( $now ); +} + +/** Statuses from which a job never runs again; reaching one is the request's closure. */ +function ct_scan_terminal_sql() { + return "'completed','failed','failed_unknown','cancelled','expired','blocked'"; +} + +/** + * When the signed authorization evidence of a job may be deleted. r2 and earlier acceptances keep + * their promised three years from acceptance; every other version keeps six years after the + * request's closure (owner decision, 2026-10-22). Never less than three years, whatever the config. + */ +function ct_scan_retain_until( $row ) { + $config = ct_scan_config(); + $legacy = (array) $config['legacy_retention_years']; + if ( isset( $legacy[ $row->terms_version ] ) ) { + $years = max( 3, (int) $legacy[ $row->terms_version ] ); + $base = $row->terms_accepted_at; + } else { + $years = max( 3, (int) $config['evidence_retention_years'] ); + $base = $row->closed_at; + } + if ( ! is_string( $base ) || '' === $base ) { + return false; + } + try { + return ( new DateTimeImmutable( $base, new DateTimeZone( 'UTC' ) ) )->modify( '+' . $years . ' years' )->format( 'Y-m-d H:i:s' ); + } catch ( Throwable $error ) { + return false; + } +} + +/** + * Is this job under a preservation hold? An unexpired hold preserves; a hold with a purpose but no + * expiry (not creatable through the admin action) also preserves, so a damaged hold never causes a + * deletion. Only an expired or absent hold lets retention proceed. + */ +function ct_scan_job_on_hold( $row, $now ) { + return ! empty( $row->hold_reason ) && ( empty( $row->hold_until ) || $row->hold_until > $now ); +} + +/** Remember a deleted record by identifier only, so a restored backup cannot bring it back. */ +function ct_scan_record_tombstone( $row, $reason ) { + global $wpdb; + $result = $wpdb->query( $wpdb->prepare( + 'INSERT IGNORE INTO ' . ct_scan_tombstones_table() . ' (job_id, request_id, scope_hash, created_at, deleted_at, reason) VALUES (%d, %d, %s, %s, %s, %s)', + (int) $row->job_id, + (int) $row->request_id, + $row->scope_hash, + $row->created_at, + ct_scan_utc_now(), + substr( sanitize_key( $reason ), 0, 32 ) + ) ); + return false !== $result; +} + +/** Delete one job and its linked private request. Returns true only if both are gone and a tombstone exists. */ +function ct_scan_delete_job_records( $row, $reason ) { + global $wpdb; + $table = ct_scan_jobs_table(); + if ( ! ct_scan_record_tombstone( $row, $reason ) ) { + return false; + } + $post = get_post( (int) $row->request_id ); + // Scrub personal fields first so a failure part-way never leaves contact data behind a deleted link. + $personal_scrubbed = $wpdb->update( $table, array( 'contact_email' => '', 'contact_name' => '' ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s' ), array( '%d' ) ); + if ( false === $personal_scrubbed ) { + return false; + } + if ( 0 === $personal_scrubbed ) { + $scrubbed = $wpdb->get_row( $wpdb->prepare( "SELECT contact_email,contact_name FROM {$table} WHERE job_id = %d", (int) $row->job_id ) ); + if ( ! $scrubbed || '' !== $scrubbed->contact_email || '' !== $scrubbed->contact_name ) { + return false; + } + } + if ( $post && 'ct_promo_request' === $post->post_type && 'private' === $post->post_status && (int) $post->ID === (int) $row->request_id ) { + if ( ! wp_delete_post( (int) $row->request_id, true ) ) { + return false; + } + } + return false !== $wpdb->delete( $table, array( 'job_id' => (int) $row->job_id ), array( '%d' ) ); +} + +/** + * Closure, findings retention (12 months), evidence retention (retain_until), narrow holds, and + * tombstones. Held jobs keep everything; nothing is deleted before its promised period. + */ +function ct_scan_retention_locked( $now ) { + global $wpdb; + $table = ct_scan_jobs_table(); + $terminal = ct_scan_terminal_sql(); + // 1. Closure is explicit: the first time a job is seen terminal it is stamped. + $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET closed_at = COALESCE(finished_at, %s) WHERE closed_at IS NULL AND status IN ({$terminal})", $now ) ); + // 2. The evidence deletion date is fixed once, at closure. + $unset = $wpdb->get_results( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL" ); + foreach ( (array) $unset as $row ) { + $when = ct_scan_retain_until( $row ); + if ( false !== $when ) { + $wpdb->update( $table, array( 'retain_until' => $when ), array( 'job_id' => (int) $row->job_id, 'retain_until' => null ), array( '%s' ), array( '%d', '%s' ) ); + } + } + // 3. Findings: twelve months after closure, unless held. + $findings_cutoff = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-12 months' )->format( 'Y-m-d H:i:s' ); $wpdb->query( $wpdb->prepare( - "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND finished_at IS NOT NULL AND finished_at < %s", - $result_cutoff + "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND closed_at IS NOT NULL AND closed_at < %s AND NOT (hold_reason IS NOT NULL AND hold_until > %s)", + $findings_cutoff, + $now ) ); - $request_cutoff = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-3 years' )->format( 'Y-m-d H:i:s' ); - $old_rows = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,request_id FROM {$table} WHERE terms_accepted_at < %s AND status IN ('completed','failed','failed_unknown','cancelled','expired')", $request_cutoff ) ); - foreach ( (array) $old_rows as $row ) { - $post = get_post( (int) $row->request_id ); - $personal_scrubbed = $wpdb->update( - $table, - array( 'contact_email' => '', 'contact_name' => '' ), - array( 'job_id' => (int) $row->job_id ), - array( '%s', '%s' ), - array( '%d' ) - ); - if ( false === $personal_scrubbed ) { + // 4. Evidence: delete what has outlived its retain_until, except under a hold. + $due = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND NOT (hold_reason IS NOT NULL AND hold_until > %s)", + $now, + $now + ) ); + foreach ( (array) $due as $row ) { + if ( ct_scan_job_on_hold( $row, $now ) ) { continue; } - if ( 0 === $personal_scrubbed ) { - $scrubbed = $wpdb->get_row( $wpdb->prepare( "SELECT contact_email,contact_name FROM {$table} WHERE job_id = %d", (int) $row->job_id ) ); - if ( ! $scrubbed || '' !== $scrubbed->contact_email || '' !== $scrubbed->contact_name ) { - continue; - } + ct_scan_delete_job_records( $row, 'retention' ); + } + // 5. A restored backup must not resurrect deleted records: delete matches again. + ct_scan_apply_tombstones( $now ); + $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE deleted_at < %s', ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . max( 90, (int) ct_scan_config()['tombstone_retention_days'] ) . ' days' )->format( 'Y-m-d H:i:s' ) ) ); +} + +/** Delete again any job row (and linked private request) that matches a deletion tombstone. */ +function ct_scan_apply_tombstones( $now ) { + global $wpdb; + $table = ct_scan_jobs_table(); + $ghosts = $wpdb->get_results( 'SELECT j.* FROM ' . $table . ' j INNER JOIN ' . ct_scan_tombstones_table() . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.scope_hash = j.scope_hash AND t.created_at = j.created_at' ); + foreach ( (array) $ghosts as $row ) { + ct_scan_delete_job_records( $row, 'restored' ); + } + // A restored request post whose job row was never restored: same identity through its saved job id. + $stones = $wpdb->get_results( 'SELECT job_id,request_id FROM ' . ct_scan_tombstones_table() . ' ORDER BY tombstone_id DESC LIMIT 500' ); + foreach ( (array) $stones as $stone ) { + $post = get_post( (int) $stone->request_id ); + if ( $post && 'ct_promo_request' === $post->post_type && 'private' === $post->post_status && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { + wp_delete_post( (int) $stone->request_id, true ); } - if ( $post && 'ct_promo_request' === $post->post_type && 'private' === $post->post_status && (int) $post->ID === (int) $row->request_id ) { - if ( ! wp_delete_post( (int) $row->request_id, true ) ) { - continue; - } + } +} + +/** + * Narrow preservation hold for one job: a stated purpose and a mandatory expiry (at most two years). + * While it stands, nothing about that job is deleted or scrubbed. Administrators only; recorded with + * who set it and when. + */ +function ct_scan_admin_set_hold( $job_id, $reason, $days ) { + global $wpdb; + $user = wp_get_current_user(); + if ( ! $user || ! $user->exists() || ! current_user_can( 'manage_options' ) ) { + return new WP_Error( 'hold_forbidden', 'Only an administrator can place a hold.' ); + } + $reason = is_string( $reason ) ? trim( wp_strip_all_tags( $reason ) ) : ''; + $days = is_numeric( $days ) ? (int) $days : 0; + if ( strlen( $reason ) < 20 || strlen( $reason ) > 190 || $days < 1 || $days > 730 ) { + return new WP_Error( 'hold_invalid', 'State the purpose (20 to 190 characters) and a duration of 1 to 730 days.' ); + } + if ( ! ct_scan_lock_acquire( 3 ) ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + try { + $row = ct_scan_row( $job_id ); + if ( ! $row ) { + return new WP_Error( 'hold_unavailable', 'This job no longer exists.' ); + } + $until = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '+' . $days . ' days' )->format( 'Y-m-d H:i:s' ); + $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_reason' => $reason, 'hold_until' => $until, 'hold_set_by' => (int) $user->ID, 'hold_set_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%d', '%s' ), array( '%d' ) ); + if ( false === $updated ) { + return new WP_Error( 'hold_write_failed', 'The hold could not be recorded.' ); + } + } finally { + ct_scan_lock_release(); + } + return true; +} + +function ct_scan_admin_release_hold( $job_id ) { + global $wpdb; + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( 'hold_forbidden', 'Only an administrator can release a hold.' ); + } + if ( ! ct_scan_lock_acquire( 3 ) ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + try { + $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_reason' => null, 'hold_until' => null ), array( 'job_id' => absint( $job_id ) ), array( '%s', '%s' ), array( '%d' ) ); + if ( false === $updated ) { + return new WP_Error( 'hold_write_failed', 'The hold could not be released.' ); } - $wpdb->delete( $table, array( 'job_id' => (int) $row->job_id ), array( '%d' ) ); + } finally { + ct_scan_lock_release(); } + return true; } function ct_scan_maintain() { @@ -2286,7 +2484,7 @@ function ct_scan_admin_action_handler() { } $job_id = isset( $_POST['job_id'] ) ? absint( wp_unslash( $_POST['job_id'] ) ) : 0; $action = isset( $_POST['ct_scan_operation'] ) && is_string( $_POST['ct_scan_operation'] ) ? sanitize_key( wp_unslash( $_POST['ct_scan_operation'] ) ) : ''; - if ( ! $job_id || ! in_array( $action, array( 'revoke', 'manual_complete', 'send_report', 'record_delivery', 'authority_review' ), true ) ) { + if ( ! $job_id || ! in_array( $action, array( 'revoke', 'manual_complete', 'send_report', 'record_delivery', 'authority_review', 'set_hold', 'release_hold' ), true ) ) { wp_die( esc_html__( 'The requested action is invalid.', 'ct-scan' ), '', array( 'response' => 400 ) ); } check_admin_referer( 'ct_scan_admin_' . $action . '_' . $job_id ); @@ -2296,6 +2494,8 @@ function ct_scan_admin_action_handler() { 'send_report' => 'ct_scan_send_confirm', 'record_delivery' => 'ct_scan_delivery_confirm', 'authority_review' => 'ct_scan_authority_confirm', + 'set_hold' => 'ct_scan_hold_confirm', + 'release_hold' => 'ct_scan_hold_release_confirm', ); $confirm_key = $confirmations[ $action ]; if ( ! isset( $_POST[ $confirm_key ] ) || ! is_string( $_POST[ $confirm_key ] ) || '1' !== wp_unslash( $_POST[ $confirm_key ] ) ) { @@ -2307,6 +2507,10 @@ function ct_scan_admin_action_handler() { $result = ct_scan_admin_save_manual_remainder( $job_id, isset( $_POST['manual_remainder_notes'] ) && is_string( $_POST['manual_remainder_notes'] ) ? $_POST['manual_remainder_notes'] : '' ); } elseif ( 'send_report' === $action ) { $result = ct_scan_admin_send_report( $job_id ); + } elseif ( 'set_hold' === $action ) { + $result = ct_scan_admin_set_hold( $job_id, isset( $_POST['ct_scan_hold_reason'] ) && is_string( $_POST['ct_scan_hold_reason'] ) ? wp_unslash( $_POST['ct_scan_hold_reason'] ) : '', isset( $_POST['ct_scan_hold_days'] ) && is_scalar( $_POST['ct_scan_hold_days'] ) ? wp_unslash( $_POST['ct_scan_hold_days'] ) : 0 ); + } elseif ( 'release_hold' === $action ) { + $result = ct_scan_admin_release_hold( $job_id ); } elseif ( 'authority_review' === $action ) { $result = ct_scan_admin_record_authority_review( $job_id, isset( $_POST['ct_scan_authority_note'] ) && is_string( $_POST['ct_scan_authority_note'] ) ? wp_unslash( $_POST['ct_scan_authority_note'] ) : '' ); } else { @@ -2404,6 +2608,11 @@ function ct_scan_admin_page() { if ( 'awaiting_control' === $row->status && empty( $row->revoked_at ) ) { echo ct_scan_admin_form( (int) $row->job_id, 'authority_review', 'Record authority review', '

' ); } + if ( ct_scan_job_on_hold( $row, ct_scan_utc_now() ) ) { + echo '

Preservation hold until ' . esc_html( $row->hold_until ) . ' UTC: ' . esc_html( $row->hold_reason ) . '

' . ct_scan_admin_form( (int) $row->job_id, 'release_hold', 'Release hold', ' ' ); + } else { + echo ct_scan_admin_form( (int) $row->job_id, 'set_hold', 'Place preservation hold', '
days
' ); + } if ( empty( $row->revoked_at ) && empty( $row->report_delivered_at ) ) { echo ct_scan_admin_form( (int) $row->job_id, 'revoke', 'Revoke authorization', ' ' ); } diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index 050d364..504f40c 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -207,7 +207,7 @@ $retained = ct_scan_row( $retention_event['job_id'] ); ct_test_assert( $retained && null === $retained->result_json && null === $retained->manual_remainder_notes && get_post( $retention_id ), '12-month cleanup must clear result/manual data but retain the request evidence.' ); $three_years = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-3 years -1 day' )->format( 'Y-m-d H:i:s' ); -$wpdb->update( $jobs, array( 'terms_accepted_at' => $three_years, 'finished_at' => $three_years ), array( 'job_id' => (int) $retention_event['job_id'] ) ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => $three_years, 'finished_at' => $three_years, 'closed_at' => $three_years, 'retain_until' => null ), array( 'job_id' => (int) $retention_event['job_id'] ) ); $hold_delete = static function ( $check, $post ) use ( $retention_id ) { return (int) $retention_id === (int) $post->ID ? false : $check; }; diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php new file mode 100644 index 0000000..310806b --- /dev/null +++ b/integrations/wordpress/tests/retention.php @@ -0,0 +1,175 @@ +modify( $modifier )->format( 'Y-m-d H:i:s' ); +} + +/** A job in $status whose acceptance and closure are the given ages. */ +function ct_test_aged_job( $host, $status, $accepted, $closed, $version = '2026-10-21-r2' ) { + global $wpdb, $request_ids; + list( $request_id, $fields ) = ct_test_request( $host ); + $request_ids[] = $request_id; + $event = ct_scan_on_request_ready( $request_id, $fields ); + ct_test_assert( is_array( $event ), 'Retention fixture job was not created.' ); + $update = array( 'status' => $status, 'terms_accepted_at' => ct_test_old( $accepted ), 'terms_version' => $version, 'confirmation_token_hash' => null ); + if ( null !== $closed ) { + $update['finished_at'] = ct_test_old( $closed ); + $update['result_json'] = '{"fixture":true}'; + $update['manual_remainder_notes'] = 'private findings'; + } + $wpdb->update( ct_scan_jobs_table(), $update, array( 'job_id' => (int) $event['job_id'] ) ); + return array( 'job_id' => (int) $event['job_id'], 'request_id' => $request_id ); +} + +function ct_test_exists( $job ) { + return (bool) ct_scan_row( $job['job_id'] ); +} + +// ---- Closure is explicit, and only terminal jobs close --------------------------------------------------- +list( $live_request, $live_fields ) = ct_test_request( 'ret-live.example.net' ); +$request_ids[] = $live_request; +$live_event = ct_scan_on_request_ready( $live_request, $live_fields ); +ct_test_queue_job( $live_event['job_id'] ); +$live = array( 'job_id' => (int) $live_event['job_id'], 'request_id' => $live_request ); +$closing = ct_test_aged_job( 'ret-closing.example.net', 'failed', '-1 day', '-1 hour' ); +$blocked = ct_test_aged_job( 'ret-blocked.example.net', 'blocked', '-2 days', '-1 day' ); +ct_scan_maintenance_locked(); +$row = ct_scan_row( $closing['job_id'] ); +ct_test_assert( $row->closed_at === $row->finished_at && ! empty( $row->retain_until ), 'A finished job was not stamped closed with a deletion date.' ); +ct_test_assert( null === ct_scan_row( $live['job_id'] )->closed_at && null === ct_scan_row( $live['job_id'] )->retain_until, 'A job that can still run was stamped closed.' ); +ct_test_assert( ! empty( ct_scan_row( $blocked['job_id'] )->closed_at ), 'A blocked job was not closed.' ); +ct_test_assert( ct_test_exists( $live ) && get_post( $live['request_id'] ), 'A job that can still run was deleted or closed by retention.' ); + +// r2: three years from acceptance. Other versions: six years after closure. +$r2 = ct_scan_row( $closing['job_id'] ); +ct_test_assert( $r2->retain_until === ( new DateTimeImmutable( $r2->terms_accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'r2 evidence is not kept three years from acceptance.' ); +$newer = ct_test_aged_job( 'ret-newer.example.net', 'cancelled', '-1 year', '-1 month', '2027-06-01-r3' ); +ct_scan_maintenance_locked(); +$nr = ct_scan_row( $newer['job_id'] ); +ct_test_assert( $nr->retain_until === ( new DateTimeImmutable( $nr->closed_at, new DateTimeZone( 'UTC' ) ) )->modify( '+6 years' )->format( 'Y-m-d H:i:s' ), 'A newer version is not kept six years after closure.' ); + +// The date is fixed at closure: a later change of the stored acceptance date does not move it. +$fixed = $nr->retain_until; +$wpdb->update( $jobs, array( 'terms_accepted_at' => ct_test_old( '-9 years' ) ), array( 'job_id' => $newer['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_scan_row( $newer['job_id'] )->retain_until === $fixed, 'The deletion date was recomputed after closure.' ); + +// An accepted-but-unverified request that expires is closed too. +list( $unverified_id, $unverified_fields ) = ct_test_request( 'ret-unverified.example.net' ); +$request_ids[] = $unverified_id; +$unverified = ct_scan_on_request_ready( $unverified_id, $unverified_fields ); +$wpdb->update( $jobs, array( 'confirmation_expires_at' => ct_test_old( '-1 hour' ) ), array( 'job_id' => $unverified['job_id'] ) ); +ct_scan_maintenance_locked(); +$u = ct_scan_row( $unverified['job_id'] ); +ct_test_assert( 'expired' === $u->status && ! empty( $u->closed_at ) && ! empty( $u->retain_until ), 'An unconfirmed request that expired was not closed with a deletion date.' ); + +// ---- Findings: twelve months after closure; evidence is kept ------------------------------------------------ +$young = ct_test_aged_job( 'ret-young.example.net', 'completed', '-2 years', '-11 months' ); +$old = ct_test_aged_job( 'ret-old.example.net', 'completed', '-2 years', '-13 months' ); +ct_scan_maintenance_locked(); +ct_test_assert( null !== ct_scan_row( $young['job_id'] )->result_json && null !== ct_scan_row( $young['job_id'] )->manual_remainder_notes, 'Findings were cleared before twelve months.' ); +$o = ct_scan_row( $old['job_id'] ); +ct_test_assert( null === $o->result_json && null === $o->manual_remainder_notes && ct_test_exists( $old ) && get_post( $old['request_id'] ), 'Findings were not cleared at twelve months, or the signed evidence went with them.' ); + +// ---- Evidence deadlines -------------------------------------------------------------------------------------- +$edge_keep = ct_test_aged_job( 'ret-keep.example.net', 'failed', '-2 years -11 months', '-1 day' ); +$edge_del = ct_test_aged_job( 'ret-del.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$six_keep = ct_test_aged_job( 'ret-six-keep.example.net', 'cancelled', '-7 years', '-5 years -11 months', '2027-06-01-r3' ); +$six_del = ct_test_aged_job( 'ret-six-del.example.net', 'cancelled', '-7 years', '-6 years -1 day', '2027-06-01-r3' ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_test_exists( $edge_keep ) && get_post( $edge_keep['request_id'] ), 'r2 evidence was deleted before three years.' ); +ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'r2 evidence was not deleted after three years.' ); +ct_test_assert( ct_test_exists( $six_keep ) && get_post( $six_keep['request_id'] ), 'Six-year evidence was deleted early.' ); +ct_test_assert( ! ct_test_exists( $six_del ) && ! get_post( $six_del['request_id'] ), 'Six-year evidence was not deleted after its period.' ); + +// ---- Tombstones: identifiers only ---------------------------------------------------------------------------- +$stone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $edge_del['job_id'] ) ); +ct_test_assert( $stone && (int) $stone->request_id === $edge_del['request_id'] && 'retention' === $stone->reason && 64 === strlen( $stone->scope_hash ), 'No tombstone recorded the deletion.' ); +$columns = $wpdb->get_col( 'SHOW COLUMNS FROM ' . ct_scan_tombstones_table(), 0 ); +ct_test_assert( array() === array_intersect( $columns, array( 'contact_email', 'contact_name', 'target_origin', 'result_json', 'terms_hash' ) ), 'The tombstone table can hold personal or customer data.' ); + +// A restored backup brings the rows back: maintenance deletes them again. +$ghost = array( 'job_id' => $edge_del['job_id'], 'request_id' => $edge_del['request_id'], 'target_origin' => 'https://ret-del.example.net/', 'scope_hash' => $stone->scope_hash, 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored Person', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $stone->created_at, 'finished_at' => ct_test_old( '-1 day' ), 'closed_at' => ct_test_old( '-1 day' ) ); +$wpdb->insert( $jobs, $ghost ); +$restored_post = wp_insert_post( array( 'import_id' => $edge_del['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); +update_post_meta( $edge_del['request_id'], '_ct_scan_job_id', $edge_del['job_id'] ); +ct_test_assert( ct_test_exists( $edge_del ) && get_post( $edge_del['request_id'] ), 'Fixture failed to restore the deleted records.' ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'A restored backup resurrected deleted records.' ); +// A different job that merely reuses the identifier is not touched. +$wpdb->insert( $jobs, array_merge( $ghost, array( 'created_at' => ct_test_old( '-1 day' ), 'status' => 'failed', 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'other@example.com' ) ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_test_exists( $edge_del ), 'A job that is not the deleted one matched the tombstone.' ); +$wpdb->delete( $jobs, array( 'job_id' => $edge_del['job_id'] ) ); +// A post with the same id but another job id is not deleted either. +$other = wp_insert_post( array( 'import_id' => $six_del['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'unrelated' ) ); +update_post_meta( $six_del['request_id'], '_ct_scan_job_id', 999999999 ); +ct_scan_maintenance_locked(); +ct_test_assert( get_post( $six_del['request_id'] ), 'A tombstone deleted a request that belongs to a different job.' ); +wp_delete_post( $six_del['request_id'], true ); +// Tombstones expire after the backup window, not before. +$wpdb->update( ct_scan_tombstones_table(), array( 'deleted_at' => ct_test_old( '-100 days' ) ), array( 'job_id' => $edge_del['job_id'] ) ); +$wpdb->update( ct_scan_tombstones_table(), array( 'deleted_at' => ct_test_old( '-130 days' ) ), array( 'job_id' => $six_del['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( null !== $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $edge_del['job_id'] ) ), 'A tombstone was purged inside the backup window.' ); +ct_test_assert( null === $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $six_del['job_id'] ) ), 'A tombstone outlived the backup window.' ); + +// ---- Narrow preservation holds --------------------------------------------------------------------------------- +$held = ct_test_aged_job( 'ret-held.example.net', 'failed', '-3 years -1 day', '-13 months' ); +wp_set_current_user( 0 ); +ct_test_assert( 'hold_forbidden' === ct_scan_admin_set_hold( $held['job_id'], 'Preserving records for dispute number 123.', 30 )->get_error_code(), 'An anonymous caller placed a hold.' ); +wp_set_current_user( 1 ); +foreach ( array( array( 'too short', 30 ), array( str_repeat( 'x', 191 ), 30 ), array( 'Preserving records for dispute number 123.', 0 ), array( 'Preserving records for dispute number 123.', 731 ), array( 'Preserving records for dispute number 123.', 'forever' ), array( 'Preserving records for dispute number 123.', -5 ) ) as $i => $bad ) { + ct_test_assert( 'hold_invalid' === ct_scan_admin_set_hold( $held['job_id'], $bad[0], $bad[1] )->get_error_code(), "Invalid hold case $i was accepted." ); +} +ct_test_assert( true === ct_scan_admin_set_hold( $held['job_id'], 'Preserving records for dispute number 123.', 30 ), 'A valid hold was refused.' ); +$h = ct_scan_row( $held['job_id'] ); +ct_test_assert( 'Preserving records for dispute number 123.' === $h->hold_reason && 1 === (int) $h->hold_set_by && $h->hold_until > ct_scan_utc_now() && $h->hold_until <= ct_test_old( '+31 days' ), 'The hold does not record its purpose, setter and expiry.' ); +ct_scan_maintenance_locked(); +$h = ct_scan_row( $held['job_id'] ); +ct_test_assert( $h && null !== $h->result_json && null !== $h->manual_remainder_notes && get_post( $held['request_id'] ), 'A held job lost records.' ); +// Once the hold expires, retention proceeds: both the findings and the evidence are past their dates. +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 minute' ) ), array( 'job_id' => $held['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $held ) && ! get_post( $held['request_id'] ), 'An expired hold still pinned the record.' ); +// Release works; a damaged hold (purpose without expiry) preserves rather than deletes. +$rel = ct_test_aged_job( 'ret-release.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_scan_admin_set_hold( $rel['job_id'], 'Preserving records for dispute number 456.', 10 ); +ct_test_assert( true === ct_scan_admin_release_hold( $rel['job_id'] ), 'Release failed.' ); +ct_test_assert( null === ct_scan_row( $rel['job_id'] )->hold_reason && null === ct_scan_row( $rel['job_id'] )->hold_until, 'Release left the hold in place.' ); +$damaged = ct_test_aged_job( 'ret-damaged.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$wpdb->update( $jobs, array( 'hold_reason' => 'Purpose without an expiry (damaged).', 'hold_until' => null ), array( 'job_id' => $damaged['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $rel ) && ct_test_exists( $damaged ), 'Release did not allow deletion, or a damaged hold allowed it.' ); +wp_set_current_user( 0 ); +ct_test_assert( 'hold_forbidden' === ct_scan_admin_release_hold( $damaged['job_id'] )->get_error_code() && ct_test_exists( $damaged ) && ! empty( ct_scan_row( $damaged['job_id'] )->hold_reason ), 'An anonymous caller released a hold.' ); + +// Deletion that cannot complete leaves the record (and its tombstone) rather than half-deleting. +$stuck = ct_test_aged_job( 'ret-stuck.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$block = static function ( $check, $post ) use ( $stuck ) { + return (int) $stuck['request_id'] === (int) $post->ID ? false : $check; +}; +add_filter( 'pre_delete_post', $block, 10, 2 ); +ct_scan_maintenance_locked(); +remove_filter( 'pre_delete_post', $block, 10 ); +$s = ct_scan_row( $stuck['job_id'] ); +ct_test_assert( $s && get_post( $stuck['request_id'] ) && '' === $s->contact_email, 'A failed deletion lost the link between the job and its request.' ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $stuck ) && ! get_post( $stuck['request_id'] ), 'The deletion was not completed on the next run.' ); + +foreach ( $request_ids as $id ) { + $wpdb->delete( $jobs, array( 'request_id' => $id ), array( '%d' ) ); + wp_delete_post( $id, true ); +} +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() ); +echo "Retention checks passed.\n"; From 0e3047db6108e891b2237fdad967721e1b760196 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 22 Oct 2026 08:26:47 +0000 Subject: [PATCH 2/9] Retention: refuse doubtful dates, honour holds in tombstone cleanup, protect live ids Independent review findings, all fixed with tests: - (blocker) a zero or garbage date was parsed into year -1 and deleted evidence on the first run: dates must now be real, post-2020 and consistent; anything doubtful is kept, and deletion also requires the stored date to be at least the derived one and three years after creation. - (high) a tombstone could delete the request of a live job reusing a recycled id: post matching now needs an opaque identity recorded at creation and no live job row using that request or job id. - (high) a trashed request outlived its deleted job with the contact details: requests are deleted in any post status. - (medium) holds are honoured by tombstone cleanup, persist for a three-day grace after expiry, and releases are audited and refuse nonexistent holds; r2-and-earlier is recognised by version rather than one string; unparseable versions get the longer period; work per run is bounded; reopening clears closure; the admin queue shows the deletion date. Schema 6 (unreleased) gains identity and hold-release columns. Co-Authored-By: Claude Code --- docs/WORDPRESS.md | 44 ++-- integrations/wordpress/ct-scan.php | 201 ++++++++++++++----- integrations/wordpress/tests/integration.php | 2 +- integrations/wordpress/tests/retention.php | 138 ++++++++++++- 4 files changed, 310 insertions(+), 75 deletions(-) diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 3ecd089..493e8ac 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -445,26 +445,40 @@ maintenance (which runs on every worker claim and on the admin queue page): (`completed`, `failed`, `failed_unknown`, `cancelled`, `expired`, `blocked`) it is stamped `closed_at`; an accepted request that was never confirmed or never showed target control closes when it expires. A job that can still run - is never closed or deleted, however old its acceptance. + is never closed or deleted, however old its dates say it is; if one is ever + reopened, its closure stamp is cleared. - **Findings** (`result_json`, manual remainder notes): cleared twelve months - after closure. The signed evidence stays. -- **Signed authorization evidence** (the job row and its linked private request): - deleted at `retain_until`, fixed once at closure: **six years after closure**, - except that r2 and earlier acceptances keep their promised **three years from - acceptance** (config `legacy_retention_years`). Never less than three years, - whatever the configuration. The archive (issue 11) can read `retain_until` per - accepted version. + (366 days, so a leap day cannot make it early) after closure. The signed + evidence stays. +- **Signed authorization evidence** (the job row and its linked private request, + in whatever post status it is in): deleted at `retain_until`, fixed once at + closure: **six years after closure**, except that r2 and earlier acceptances + (versions `YYYY-MM-DD-rN` up to `2026-10-21-r2`) keep their promised **three + years from acceptance**. Never less than three years, whatever the + configuration; a version that cannot be parsed gets the longer period. +- **Doubtful data is kept.** Deletion requires every date involved to be a real + `Y-m-d H:i:s` date no earlier than 2020 (zero dates and other restore debris + are refused), the stored deletion date to be no earlier than the one derived + from the row today and no earlier than three years after the job's creation, + and the job to be in a terminal state. A job whose base date cannot be trusted + keeps `retain_until` empty and is shown as "unknown (kept)" in the admin queue. + Each run deletes at most 50 jobs, so the queue lock is held briefly. - **Narrow preservation hold** (admin queue): one job, a stated purpose of 20 to 190 characters, a mandatory expiry of at most 730 days, recorded with who set - it and when. While it stands nothing about that job is cleared or deleted; an - expired or released hold lets retention proceed, and a damaged hold (purpose + it and when (and who released it). While it stands nothing about that job is + cleared or deleted, including by a tombstone; after it expires or is released, + the job stays preserved for a further three days. A damaged hold (purpose without an expiry) preserves rather than deletes. - **Tombstones.** Deleting a job writes an identifier-only tombstone (job id, - request id, scope hash, creation time, time and reason; no personal or - customer data) before the deletion. If a restored backup brings the rows back, - the next maintenance run deletes them again, and a restored private request is - deleted only if its saved job id is the tombstoned one. Tombstones are kept 120 - days, longer than the 90-day backup rotation, then purged. + request id, scope hash, creation time, an opaque random identity, time and + reason; no personal or customer data) before the deletion. If a restored backup + brings the rows back, the next maintenance run deletes them again: a job row + matches on its ids, scope and creation time; a restored request post matches + only on its opaque identity and only when no job row currently uses that request + or job id (auto-increment ids can be recycled after a restore and must never + reach a live record). Jobs created before the identity existed are recognised by + their job row only. Tombstones are kept 120 days, longer than the 90-day backup + rotation, then purged. - Deletion scrubs the contact fields first, then deletes the private request and the job row; if any step fails the record stays (and is retried) rather than being half-deleted. diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 1779f5a..097e4c3 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -90,7 +90,7 @@ function ct_scan_config() { // Owner decisions (2026-10-22): findings 12 months; signed authorization evidence 6 years // after request closure, except r2 and earlier acceptances, which keep 3 years from acceptance. 'evidence_retention_years' => 6, - 'legacy_retention_years' => array( '2026-10-21-r2' => 3 ), + 'legacy_retention_years' => 3, // How long a deletion tombstone is kept: longer than the longest backup rotation (90 days). 'tombstone_retention_days' => 120, ); @@ -216,6 +216,9 @@ function ct_scan_maybe_install_schema() { hold_until datetime NULL, hold_set_by bigint(20) unsigned NULL, hold_set_at datetime NULL, + hold_released_by bigint(20) unsigned NULL, + hold_released_at datetime NULL, + identity char(32) NULL, PRIMARY KEY (job_id), UNIQUE KEY request_id (request_id), KEY status_created (status,created_at), @@ -237,6 +240,7 @@ function ct_scan_maybe_install_schema() { created_at datetime NOT NULL, deleted_at datetime NOT NULL, reason varchar(32) NOT NULL, + identity char(32) NULL, PRIMARY KEY (tombstone_id), UNIQUE KEY identity (job_id,request_id,scope_hash,created_at), KEY deleted_at (deleted_at) @@ -249,9 +253,9 @@ function ct_scan_maybe_install_schema() { ) {$charset};"; dbDelta( $sql ); $required = array( - $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence', 'closed_at', 'retain_until', 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at' ), + $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence', 'closed_at', 'retain_until', 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at', 'hold_released_by', 'hold_released_at', 'identity' ), $nonces => array( 'nonce_hash', 'expires_at' ), - $tombstones => array( 'tombstone_id', 'job_id', 'request_id', 'scope_hash', 'created_at', 'deleted_at', 'reason' ), + $tombstones => array( 'tombstone_id', 'job_id', 'request_id', 'scope_hash', 'created_at', 'deleted_at', 'reason', 'identity' ), ); foreach ( $required as $table => $columns ) { $found = $wpdb->get_col( "SHOW COLUMNS FROM {$table}", 0 ); @@ -606,6 +610,7 @@ function ct_scan_on_request_ready( $request_id, $fields ) { if ( $target_count >= min( 10, max( 1, absint( $config['target_daily_limit'] ) ) ) ) { return ct_scan_safe_notice( $request_id, 'target_quota' ); } + $identity = bin2hex( random_bytes( 16 ) ); $inserted = $wpdb->insert( $jobs, array( @@ -627,11 +632,14 @@ function ct_scan_on_request_ready( $request_id, $fields ) { 'profile_id' => ct_scan_current_profile(), 'summary_sha256' => $accepted['summary_sha256'], 'bound_required' => $accepted['bound_required'] ? 1 : 0, + // Opaque identity mirrored on the private request: lets a deletion tombstone recognise + // the same record after a restore without matching on recyclable numeric ids. + 'identity' => $identity, // Frozen at creation: a later configuration change cannot relax an existing job. // A profile broader than the r2 header screening can never run without target control. 'control_required' => ( in_array( $accepted['terms_version'], (array) $config['target_control_versions'], true ) || 'r2-header-v1' !== ct_scan_current_profile() ) ? 1 : 0, ), - array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d' ) + array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ) ); if ( 1 !== $inserted ) { $existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE request_id = %d LIMIT 1", $request_id ) ); @@ -647,6 +655,7 @@ function ct_scan_on_request_ready( $request_id, $fields ) { $job_id = (int) $wpdb->insert_id; update_post_meta( $request_id, '_ct_scan_event_recorded', '1' ); update_post_meta( $request_id, '_ct_scan_job_id', $job_id ); + update_post_meta( $request_id, '_ct_scan_identity', $identity ); update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $accepted['authorization_expires'] ); ct_scan_mark_request_status( $request_id, 'awaiting_confirmation' ); } finally { @@ -775,26 +784,62 @@ function ct_scan_maintenance_locked() { } /** Statuses from which a job never runs again; reaching one is the request's closure. */ +function ct_scan_terminal_statuses() { + return array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired', 'blocked' ); +} + function ct_scan_terminal_sql() { - return "'completed','failed','failed_unknown','cancelled','expired','blocked'"; + return "'" . implode( "','", ct_scan_terminal_statuses() ) . "'"; +} + +/** + * A stored UTC datetime we are willing to compute a deletion date from: the exact `Y-m-d H:i:s` + * shape, a real calendar date, and not earlier than 2020 (zero dates, which PHP silently parses + * into year -1, and other restore/migration debris are refused rather than guessed at). + */ +function ct_scan_valid_datetime( $value ) { + if ( ! is_string( $value ) || ! preg_match( '/\A(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\z/', $value, $m ) || (int) $m[1] < 2020 ) { + return false; + } + if ( ! checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) || (int) $m[4] > 23 || (int) $m[5] > 59 || (int) $m[6] > 59 ) { + return false; + } + return true; +} + +/** Retention periods are never shorter than the three years every acceptance has been promised. */ +function ct_scan_clamp_years( $years ) { + return is_numeric( $years ) ? max( 3, (int) $years ) : 3; } /** - * When the signed authorization evidence of a job may be deleted. r2 and earlier acceptances keep - * their promised three years from acceptance; every other version keeps six years after the - * request's closure (owner decision, 2026-10-22). Never less than three years, whatever the config. + * r2 and earlier acceptances keep three years from acceptance; this recognises them by version + * (`YYYY-MM-DD-rN` up to `2026-10-21-r2`). A version that cannot be parsed is NOT treated as legacy: + * it gets the longer period, so a surprise string can never shorten retention. + */ +function ct_scan_is_legacy_version( $version ) { + if ( ! is_string( $version ) || ! preg_match( '/\A(\d{4}-\d{2}-\d{2})-r(\d{1,3})\z/', $version, $m ) ) { + return false; + } + return $m[1] < '2026-10-21' || ( '2026-10-21' === $m[1] && (int) $m[2] <= 2 ); +} + +/** + * When the signed authorization evidence of a job may be deleted: r2 and earlier acceptances three + * years from acceptance; every other version six years after the request's closure (owner decision, + * 2026-10-22). Returns false, and the job is then never deleted, if the base date is not trustworthy. + * Month/year arithmetic that overflows (Feb 29) lands later, never earlier. */ function ct_scan_retain_until( $row ) { $config = ct_scan_config(); - $legacy = (array) $config['legacy_retention_years']; - if ( isset( $legacy[ $row->terms_version ] ) ) { - $years = max( 3, (int) $legacy[ $row->terms_version ] ); + if ( ct_scan_is_legacy_version( $row->terms_version ) ) { + $years = ct_scan_clamp_years( $config['legacy_retention_years'] ); $base = $row->terms_accepted_at; } else { - $years = max( 3, (int) $config['evidence_retention_years'] ); + $years = ct_scan_clamp_years( $config['evidence_retention_years'] ); $base = $row->closed_at; } - if ( ! is_string( $base ) || '' === $base ) { + if ( ! ct_scan_valid_datetime( $base ) ) { return false; } try { @@ -804,31 +849,66 @@ function ct_scan_retain_until( $row ) { } } +/** The grace period after a hold expires before its job becomes deletable (nothing vanishes the minute a hold lapses). */ +function ct_scan_hold_grace_days() { + return 3; +} + /** - * Is this job under a preservation hold? An unexpired hold preserves; a hold with a purpose but no - * expiry (not creatable through the admin action) also preserves, so a damaged hold never causes a - * deletion. Only an expired or absent hold lets retention proceed. + * Is this job under a preservation hold? An unexpired hold preserves, and so does one that expired + * less than the grace period ago; a hold with a purpose but no expiry (not creatable through the admin + * action) also preserves, so a damaged hold never causes a deletion. */ function ct_scan_job_on_hold( $row, $now ) { - return ! empty( $row->hold_reason ) && ( empty( $row->hold_until ) || $row->hold_until > $now ); + if ( empty( $row->hold_reason ) ) { + return false; + } + if ( empty( $row->hold_until ) || ! ct_scan_valid_datetime( $row->hold_until ) ) { + return true; + } + $grace_end = ( new DateTimeImmutable( $row->hold_until, new DateTimeZone( 'UTC' ) ) )->modify( '+' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); + return $grace_end > $now; +} + +/** + * May this job's records be deleted now? Every date involved must be trustworthy, the stored date must + * not be earlier than the one derived from the row today or than three years after creation, and no hold + * may apply. Anything doubtful is kept. + */ +function ct_scan_deletion_allowed( $row, $now ) { + if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) || ct_scan_job_on_hold( $row, $now ) ) { + return false; + } + foreach ( array( $row->created_at, $row->closed_at, $row->retain_until ) as $date ) { + if ( ! ct_scan_valid_datetime( $date ) ) { + return false; + } + } + $expected = ct_scan_retain_until( $row ); + if ( false === $expected || $row->retain_until < $expected || $row->retain_until < $row->closed_at ) { + return false; + } + $floor = ( new DateTimeImmutable( $row->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ); + return $row->retain_until >= $floor && $row->retain_until <= $now; } /** Remember a deleted record by identifier only, so a restored backup cannot bring it back. */ function ct_scan_record_tombstone( $row, $reason ) { global $wpdb; $result = $wpdb->query( $wpdb->prepare( - 'INSERT IGNORE INTO ' . ct_scan_tombstones_table() . ' (job_id, request_id, scope_hash, created_at, deleted_at, reason) VALUES (%d, %d, %s, %s, %s, %s)', + 'INSERT IGNORE INTO ' . ct_scan_tombstones_table() . ' (job_id, request_id, scope_hash, created_at, deleted_at, reason, identity) VALUES (%d, %d, %s, %s, %s, %s, %s)', (int) $row->job_id, (int) $row->request_id, $row->scope_hash, $row->created_at, ct_scan_utc_now(), - substr( sanitize_key( $reason ), 0, 32 ) + substr( sanitize_key( $reason ), 0, 32 ), + is_string( $row->identity ) ? $row->identity : '' ) ); return false !== $result; } -/** Delete one job and its linked private request. Returns true only if both are gone and a tombstone exists. */ +/** Delete one job and its linked request, whatever status that post is in. True only if both are gone and a tombstone exists. */ function ct_scan_delete_job_records( $row, $reason ) { global $wpdb; $table = ct_scan_jobs_table(); @@ -847,7 +927,9 @@ function ct_scan_delete_job_records( $row, $reason ) { return false; } } - if ( $post && 'ct_promo_request' === $post->post_type && 'private' === $post->post_status && (int) $post->ID === (int) $row->request_id ) { + // The request carries the contact details and the full contract copy: delete it in any status + // (a trashed or re-published copy must not outlive the job that pointed to it). + if ( $post && 'ct_promo_request' === $post->post_type && (int) $post->ID === (int) $row->request_id ) { if ( ! wp_delete_post( (int) $row->request_id, true ) ) { return false; } @@ -857,69 +939,79 @@ function ct_scan_delete_job_records( $row, $reason ) { /** * Closure, findings retention (12 months), evidence retention (retain_until), narrow holds, and - * tombstones. Held jobs keep everything; nothing is deleted before its promised period. + * tombstones. Held jobs keep everything; nothing is deleted before its promised period, and every + * step is bounded per run so the global queue lock is not held for long. */ function ct_scan_retention_locked( $now ) { global $wpdb; $table = ct_scan_jobs_table(); $terminal = ct_scan_terminal_sql(); + // 0. A job that can run again is not closed: forget any closure stamped on it. + $wpdb->query( "UPDATE {$table} SET closed_at = NULL, retain_until = NULL WHERE status NOT IN ({$terminal}) AND (closed_at IS NOT NULL OR retain_until IS NOT NULL)" ); // 1. Closure is explicit: the first time a job is seen terminal it is stamped. $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET closed_at = COALESCE(finished_at, %s) WHERE closed_at IS NULL AND status IN ({$terminal})", $now ) ); - // 2. The evidence deletion date is fixed once, at closure. - $unset = $wpdb->get_results( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL" ); + // 2. The evidence deletion date is fixed once, at closure. An untrustworthy base leaves it NULL (kept). + $unset = $wpdb->get_results( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL LIMIT 200" ); foreach ( (array) $unset as $row ) { $when = ct_scan_retain_until( $row ); if ( false !== $when ) { $wpdb->update( $table, array( 'retain_until' => $when ), array( 'job_id' => (int) $row->job_id, 'retain_until' => null ), array( '%s' ), array( '%d', '%s' ) ); } } - // 3. Findings: twelve months after closure, unless held. - $findings_cutoff = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-12 months' )->format( 'Y-m-d H:i:s' ); + // 3. Findings: twelve months after closure (366 days, so a leap day cannot make it early), unless held. + $findings_cutoff = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-366 days' )->format( 'Y-m-d H:i:s' ); + $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); $wpdb->query( $wpdb->prepare( - "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND closed_at IS NOT NULL AND closed_at < %s AND NOT (hold_reason IS NOT NULL AND hold_until > %s)", + "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND closed_at IS NOT NULL AND closed_at >= '2020-01-01 00:00:00' AND closed_at < %s AND (hold_reason IS NULL OR hold_until <= %s)", $findings_cutoff, - $now + $grace_edge ) ); - // 4. Evidence: delete what has outlived its retain_until, except under a hold. + // 4. Evidence: delete what has outlived its retain_until, except under a hold. Bounded per run. $due = $wpdb->get_results( $wpdb->prepare( - "SELECT * FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND NOT (hold_reason IS NOT NULL AND hold_until > %s)", + "SELECT * FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND (hold_reason IS NULL OR hold_until <= %s) ORDER BY retain_until ASC LIMIT 50", $now, - $now + $grace_edge ) ); foreach ( (array) $due as $row ) { - if ( ct_scan_job_on_hold( $row, $now ) ) { - continue; + if ( ct_scan_deletion_allowed( $row, $now ) ) { + ct_scan_delete_job_records( $row, 'retention' ); } - ct_scan_delete_job_records( $row, 'retention' ); } // 5. A restored backup must not resurrect deleted records: delete matches again. ct_scan_apply_tombstones( $now ); $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE deleted_at < %s', ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . max( 90, (int) ct_scan_config()['tombstone_retention_days'] ) . ' days' )->format( 'Y-m-d H:i:s' ) ) ); } -/** Delete again any job row (and linked private request) that matches a deletion tombstone. */ +/** + * Delete again any job row (and linked request) that a deletion tombstone recognises after a restore. + * A job row matches on its numeric identifiers AND creation time AND scope; a request post matches only + * on the opaque identity recorded at creation, and only when no job row currently uses that request or + * job id (recycled auto-increment ids must never reach a live record). Holds are honoured. + */ function ct_scan_apply_tombstones( $now ) { global $wpdb; - $table = ct_scan_jobs_table(); - $ghosts = $wpdb->get_results( 'SELECT j.* FROM ' . $table . ' j INNER JOIN ' . ct_scan_tombstones_table() . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.scope_hash = j.scope_hash AND t.created_at = j.created_at' ); + $table = ct_scan_jobs_table(); + $stones = ct_scan_tombstones_table(); + $ghosts = $wpdb->get_results( 'SELECT j.* FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.scope_hash = j.scope_hash AND t.created_at = j.created_at ORDER BY j.job_id ASC LIMIT 50' ); foreach ( (array) $ghosts as $row ) { - ct_scan_delete_job_records( $row, 'restored' ); + if ( ! ct_scan_job_on_hold( $row, $now ) ) { + ct_scan_delete_job_records( $row, 'restored' ); + } } - // A restored request post whose job row was never restored: same identity through its saved job id. - $stones = $wpdb->get_results( 'SELECT job_id,request_id FROM ' . ct_scan_tombstones_table() . ' ORDER BY tombstone_id DESC LIMIT 500' ); - foreach ( (array) $stones as $stone ) { - $post = get_post( (int) $stone->request_id ); - if ( $post && 'ct_promo_request' === $post->post_type && 'private' === $post->post_status && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { + // A restored request post whose job row was not restored (or was restored without matching). + $posts = $wpdb->posts; + $restored = $wpdb->get_results( 'SELECT t.job_id, t.request_id, t.identity FROM ' . $stones . ' t INNER JOIN ' . $posts . " p ON p.ID = t.request_id AND p.post_type = 'ct_promo_request' WHERE t.identity IS NOT NULL AND t.identity <> '' ORDER BY t.tombstone_id DESC LIMIT 50" ); + foreach ( (array) $restored as $stone ) { + $live = $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$table} WHERE request_id = %d OR job_id = %d LIMIT 1", (int) $stone->request_id, (int) $stone->job_id ) ); + if ( $live ) { + continue; + } + if ( hash_equals( (string) $stone->identity, ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_identity' ) ) && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { wp_delete_post( (int) $stone->request_id, true ); } } } -/** - * Narrow preservation hold for one job: a stated purpose and a mandatory expiry (at most two years). - * While it stands, nothing about that job is deleted or scrubbed. Administrators only; recorded with - * who set it and when. - */ function ct_scan_admin_set_hold( $job_id, $reason, $days ) { global $wpdb; $user = wp_get_current_user(); @@ -952,14 +1044,19 @@ function ct_scan_admin_set_hold( $job_id, $reason, $days ) { function ct_scan_admin_release_hold( $job_id ) { global $wpdb; - if ( ! current_user_can( 'manage_options' ) ) { + $user = wp_get_current_user(); + if ( ! $user || ! $user->exists() || ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'hold_forbidden', 'Only an administrator can release a hold.' ); } if ( ! ct_scan_lock_acquire( 3 ) ) { return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); } try { - $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_reason' => null, 'hold_until' => null ), array( 'job_id' => absint( $job_id ) ), array( '%s', '%s' ), array( '%d' ) ); + $row = ct_scan_row( $job_id ); + if ( ! $row || empty( $row->hold_reason ) ) { + return new WP_Error( 'hold_unavailable', 'This job has no hold to release.' ); + } + $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_reason' => null, 'hold_until' => null, 'hold_released_by' => (int) $user->ID, 'hold_released_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%d', '%s' ), array( '%d' ) ); if ( false === $updated ) { return new WP_Error( 'hold_write_failed', 'The hold could not be released.' ); } @@ -2265,7 +2362,7 @@ function ct_scan_register_rest_routes() { add_action( 'rest_api_init', 'ct_scan_register_rest_routes' ); function ct_scan_terminal_status( $status ) { - return in_array( $status, array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired', 'blocked' ), true ); + return in_array( $status, ct_scan_terminal_statuses(), true ); } function ct_scan_email_safe_text( $value, $max = 2000 ) { @@ -2618,7 +2715,7 @@ function ct_scan_admin_page() { foreach ( $rows as $row ) { $request_url = admin_url( 'post.php?post=' . (int) $row->request_id . '&action=edit' ); $host = wp_parse_url( $row->target_origin, PHP_URL_HOST ); - $exec = 'Status: ' . $row->status . "\nAttempts: " . (int) $row->attempts . "\nOutcome: " . ( $row->outcome ? $row->outcome : 'pending' ) . "\nTarget control: " . ( $row->control_required ? ( $row->control_verified_at ? sanitize_key( (string) $row->control_method ) . ' at ' . $row->control_verified_at . ' UTC' : 'required, not shown (' . (int) $row->control_attempts . ' of 3 checks used)' ) : 'not required' ) . "\nError: " . ( $row->error_code ? sanitize_key( $row->error_code ) : '—' ) . ( 'manual_review' === $row->control_method ? "\nAuthority review: " . ct_scan_control_evidence_line( $row ) : '' ) . "\nChecker revision: " . ( $row->checker_revision ? substr( $row->checker_revision, 0, 12 ) . '…' : '—' ); + $exec = 'Status: ' . $row->status . "\nAttempts: " . (int) $row->attempts . "\nOutcome: " . ( $row->outcome ? $row->outcome : 'pending' ) . "\nTarget control: " . ( $row->control_required ? ( $row->control_verified_at ? sanitize_key( (string) $row->control_method ) . ' at ' . $row->control_verified_at . ' UTC' : 'required, not shown (' . (int) $row->control_attempts . ' of 3 checks used)' ) : 'not required' ) . "\nClosed: " . ( $row->closed_at ? $row->closed_at . ' UTC' : '—' ) . "\nEvidence deletion date: " . ( $row->retain_until ? $row->retain_until . ' UTC' : ( $row->closed_at ? 'unknown (kept)' : '—' ) ) . "\nError: " . ( $row->error_code ? sanitize_key( $row->error_code ) : '—' ) . ( 'manual_review' === $row->control_method ? "\nAuthority review: " . ct_scan_control_evidence_line( $row ) : '' ) . "\nChecker revision: " . ( $row->checker_revision ? substr( $row->checker_revision, 0, 12 ) . '…' : '—' ); $mail = 'Confirmation email: ' . sanitize_key( $row->confirmation_email_status ) . "\nReport transport: " . sanitize_key( $row->report_notification_status ) . "\nDelivery confirmed: " . ( $row->report_delivered_at ? $row->report_delivered_at . ' UTC' : 'no' ) . "\nRevoked: " . ( $row->revoked_at ? $row->revoked_at . ' UTC' : 'no' ); echo 'Job #' . (int) $row->job_id . ' / request #' . (int) $row->request_id . '
Created ' . esc_html( $row->created_at ) . ' UTC'; echo '' . esc_html( $row->target_origin ) . '
Terms ' . esc_html( $row->terms_version ) . '
Accepted ' . esc_html( $row->terms_accepted_at ) . ' UTC
Expires ' . esc_html( $row->authorization_expires_at ) . ' UTC
Scope hash ' . esc_html( substr( $row->scope_hash, 0, 16 ) ) . '…'; diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index 504f40c..71851c6 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -207,7 +207,7 @@ $retained = ct_scan_row( $retention_event['job_id'] ); ct_test_assert( $retained && null === $retained->result_json && null === $retained->manual_remainder_notes && get_post( $retention_id ), '12-month cleanup must clear result/manual data but retain the request evidence.' ); $three_years = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-3 years -1 day' )->format( 'Y-m-d H:i:s' ); -$wpdb->update( $jobs, array( 'terms_accepted_at' => $three_years, 'finished_at' => $three_years, 'closed_at' => $three_years, 'retain_until' => null ), array( 'job_id' => (int) $retention_event['job_id'] ) ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => $three_years, 'finished_at' => $three_years, 'closed_at' => $three_years, 'created_at' => $three_years, 'retain_until' => null ), array( 'job_id' => (int) $retention_event['job_id'] ) ); $hold_delete = static function ( $check, $post ) use ( $retention_id ) { return (int) $retention_id === (int) $post->ID ? false : $check; }; diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 310806b..2d30ec1 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -7,6 +7,9 @@ global $wpdb; $jobs = ct_scan_jobs_table(); ct_test_purge_fixtures(); +// Leftovers of an aborted earlier run of this script. +$wpdb->query( "DELETE FROM {$jobs} WHERE job_id >= 888000000" ); +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() ); $request_ids = array(); add_filter( 'pre_wp_mail', '__return_true' ); @@ -21,7 +24,7 @@ function ct_test_aged_job( $host, $status, $accepted, $closed, $version = '2026- $request_ids[] = $request_id; $event = ct_scan_on_request_ready( $request_id, $fields ); ct_test_assert( is_array( $event ), 'Retention fixture job was not created.' ); - $update = array( 'status' => $status, 'terms_accepted_at' => ct_test_old( $accepted ), 'terms_version' => $version, 'confirmation_token_hash' => null ); + $update = array( 'status' => $status, 'terms_accepted_at' => ct_test_old( $accepted ), 'created_at' => ct_test_old( $accepted ), 'terms_version' => $version, 'confirmation_token_hash' => null ); if ( null !== $closed ) { $update['finished_at'] = ct_test_old( $closed ); $update['result_json'] = '{"fixture":true}'; @@ -84,8 +87,8 @@ function ct_test_exists( $job ) { // ---- Evidence deadlines -------------------------------------------------------------------------------------- $edge_keep = ct_test_aged_job( 'ret-keep.example.net', 'failed', '-2 years -11 months', '-1 day' ); $edge_del = ct_test_aged_job( 'ret-del.example.net', 'failed', '-3 years -1 day', '-1 day' ); -$six_keep = ct_test_aged_job( 'ret-six-keep.example.net', 'cancelled', '-7 years', '-5 years -11 months', '2027-06-01-r3' ); -$six_del = ct_test_aged_job( 'ret-six-del.example.net', 'cancelled', '-7 years', '-6 years -1 day', '2027-06-01-r3' ); +$six_keep = ct_test_aged_job( 'ret-six-keep.example.net', 'cancelled', '-6 years -2 days', '-5 years -11 months', '2027-06-01-r3' ); +$six_del = ct_test_aged_job( 'ret-six-del.example.net', 'cancelled', '-6 years -2 days', '-6 years -1 day', '2027-06-01-r3' ); ct_scan_maintenance_locked(); ct_test_assert( ct_test_exists( $edge_keep ) && get_post( $edge_keep['request_id'] ), 'r2 evidence was deleted before three years.' ); ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'r2 evidence was not deleted after three years.' ); @@ -103,6 +106,8 @@ function ct_test_exists( $job ) { $wpdb->insert( $jobs, $ghost ); $restored_post = wp_insert_post( array( 'import_id' => $edge_del['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); update_post_meta( $edge_del['request_id'], '_ct_scan_job_id', $edge_del['job_id'] ); +update_post_meta( $edge_del['request_id'], '_ct_scan_identity', $stone->identity ); +ct_test_assert( 32 === strlen( (string) $stone->identity ), 'The tombstone did not keep the opaque identity.' ); ct_test_assert( ct_test_exists( $edge_del ) && get_post( $edge_del['request_id'] ), 'Fixture failed to restore the deleted records.' ); ct_scan_maintenance_locked(); ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'A restored backup resurrected deleted records.' ); @@ -116,7 +121,22 @@ function ct_test_exists( $job ) { update_post_meta( $six_del['request_id'], '_ct_scan_job_id', 999999999 ); ct_scan_maintenance_locked(); ct_test_assert( get_post( $six_del['request_id'] ), 'A tombstone deleted a request that belongs to a different job.' ); -wp_delete_post( $six_del['request_id'], true ); +// Same id and job id but not the same identity (a recycled id after a restore): untouched. +$six_stone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $six_del['job_id'] ) ); +update_post_meta( $six_del['request_id'], '_ct_scan_job_id', $six_del['job_id'] ); +update_post_meta( $six_del['request_id'], '_ct_scan_identity', str_repeat( 'f', 32 ) ); +ct_scan_maintenance_locked(); +ct_test_assert( get_post( $six_del['request_id'] ), 'A tombstone deleted a request with a different identity.' ); +// Same identity but a live job row now uses that request id: a recycled id must never reach a live record. +update_post_meta( $six_del['request_id'], '_ct_scan_identity', $six_stone->identity ); +$wpdb->insert( $jobs, array( 'job_id' => 888000001, 'request_id' => $six_del['request_id'], 'target_origin' => 'https://recycled.example.net/', 'scope_hash' => str_repeat( 'c', 64 ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'live@example.com', 'contact_name' => 'Live', 'referrer' => 'direct', 'status' => 'awaiting_confirmation', 'created_at' => ct_test_old( '-1 hour' ) ) ); +ct_scan_maintenance_locked(); +ct_test_assert( get_post( $six_del['request_id'] ), 'A tombstone deleted the request of a live job that reuses its id.' ); +$wpdb->delete( $jobs, array( 'job_id' => 888000001 ) ); +// With no live row, a matching identity does delete a restored post, in any post status. +wp_update_post( array( 'ID' => $six_del['request_id'], 'post_status' => 'trash' ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ! get_post( $six_del['request_id'] ), 'A restored request in the trash was not deleted.' ); // Tombstones expire after the backup window, not before. $wpdb->update( ct_scan_tombstones_table(), array( 'deleted_at' => ct_test_old( '-100 days' ) ), array( 'job_id' => $edge_del['job_id'] ) ); $wpdb->update( ct_scan_tombstones_table(), array( 'deleted_at' => ct_test_old( '-130 days' ) ), array( 'job_id' => $six_del['job_id'] ) ); @@ -128,6 +148,9 @@ function ct_test_exists( $job ) { $held = ct_test_aged_job( 'ret-held.example.net', 'failed', '-3 years -1 day', '-13 months' ); wp_set_current_user( 0 ); ct_test_assert( 'hold_forbidden' === ct_scan_admin_set_hold( $held['job_id'], 'Preserving records for dispute number 123.', 30 )->get_error_code(), 'An anonymous caller placed a hold.' ); +$subscriber = wp_insert_user( array( 'user_login' => 'ct_ret_sub_' . wp_generate_password( 6, false ), 'user_pass' => wp_generate_password(), 'role' => 'subscriber' ) ); +wp_set_current_user( (int) $subscriber ); +ct_test_assert( 'hold_forbidden' === ct_scan_admin_set_hold( $held['job_id'], 'Preserving records for dispute number 123.', 30 )->get_error_code() && 'hold_forbidden' === ct_scan_admin_release_hold( $held['job_id'] )->get_error_code(), 'A logged-in non-administrator managed a hold.' ); wp_set_current_user( 1 ); foreach ( array( array( 'too short', 30 ), array( str_repeat( 'x', 191 ), 30 ), array( 'Preserving records for dispute number 123.', 0 ), array( 'Preserving records for dispute number 123.', 731 ), array( 'Preserving records for dispute number 123.', 'forever' ), array( 'Preserving records for dispute number 123.', -5 ) ) as $i => $bad ) { ct_test_assert( 'hold_invalid' === ct_scan_admin_set_hold( $held['job_id'], $bad[0], $bad[1] )->get_error_code(), "Invalid hold case $i was accepted." ); @@ -138,15 +161,116 @@ function ct_test_exists( $job ) { ct_scan_maintenance_locked(); $h = ct_scan_row( $held['job_id'] ); ct_test_assert( $h && null !== $h->result_json && null !== $h->manual_remainder_notes && get_post( $held['request_id'] ), 'A held job lost records.' ); -// Once the hold expires, retention proceeds: both the findings and the evidence are past their dates. +// A hold that just expired still preserves for the grace period, so nothing vanishes the minute it lapses. $wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 minute' ) ), array( 'job_id' => $held['job_id'] ) ); ct_scan_maintenance_locked(); -ct_test_assert( ! ct_test_exists( $held ) && ! get_post( $held['request_id'] ), 'An expired hold still pinned the record.' ); +ct_test_assert( ct_test_exists( $held ) && null !== ct_scan_row( $held['job_id'] )->result_json, 'A hold that expired a minute ago released its records at once.' ); +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-4 days' ) ), array( 'job_id' => $held['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $held ) && ! get_post( $held['request_id'] ), 'An expired hold still pinned the record after its grace period.' ); +// The tombstone path honours a hold too: a restored row under a hold is kept. +$restore_held = ct_test_aged_job( 'ret-restore-held.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $restore_held ), 'Fixture expected the job to be deleted first.' ); +$rstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $restore_held['job_id'] ) ); +$wpdb->insert( $jobs, array( 'job_id' => $restore_held['job_id'], 'request_id' => $restore_held['request_id'], 'target_origin' => 'https://ret-restore-held.example.net/', 'scope_hash' => $rstone->scope_hash, 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'held@example.com', 'contact_name' => 'Held', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $rstone->created_at, 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_test_exists( $restore_held ), 'A restored job under a hold was deleted by its tombstone.' ); +$wpdb->delete( $jobs, array( 'job_id' => $restore_held['job_id'] ) ); + +// ---- Untrustworthy dates and doubtful rows are kept, never deleted -------------------------------------------- +$zero_r2 = ct_test_aged_job( 'ret-zero-r2.example.net', 'failed', '-3 years -1 day', '-1 hour' ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => '0000-00-00 00:00:00', 'closed_at' => null, 'retain_until' => null ), array( 'job_id' => $zero_r2['job_id'] ) ); +$zero_new = ct_test_aged_job( 'ret-zero-new.example.net', 'cancelled', '-1 year', '-1 hour', '2027-06-01-r3' ); +$wpdb->update( $jobs, array( 'finished_at' => '0000-00-00 00:00:00', 'closed_at' => '0000-00-00 00:00:00', 'retain_until' => null ), array( 'job_id' => $zero_new['job_id'] ) ); +$garbled = ct_test_aged_job( 'ret-garbled.example.net', 'failed', '-3 years -1 day', '-1 hour' ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => '1999-01-01 00:00:00', 'closed_at' => null, 'retain_until' => null ), array( 'job_id' => $garbled['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_scan_maintenance_locked(); +foreach ( array( $zero_r2, $zero_new, $garbled ) as $case ) { + $r = ct_scan_row( $case['job_id'] ); + ct_test_assert( $r && null === $r->retain_until && get_post( $case['request_id'] ), 'A job with an untrustworthy date was given a deletion date or deleted.' ); +} +ct_test_assert( null !== ct_scan_row( $zero_new['job_id'] )->result_json, 'Findings were cleared on the strength of a zero date.' ); +// A stored date earlier than the one derived today, or earlier than three years after creation, is not honoured. +$tampered = ct_test_aged_job( 'ret-tampered.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $tampered ), 'Fixture expected a normal due job to be deleted.' ); +$early = ct_test_aged_job( 'ret-early.example.net', 'failed', '-2 years', '-1 day' ); +ct_scan_maintenance_locked(); +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $early['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_test_exists( $early ) && get_post( $early['request_id'] ), 'A retain_until earlier than three years after creation (or the derived date) deleted the evidence.' ); +// The derived-date check alone (the creation floor passes): accepted two years ago, created four years ago. +$early2 = ct_test_aged_job( 'ret-early2.example.net', 'failed', '-2 years', '-1 day' ); +$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-4 years' ) ), array( 'job_id' => $early2['job_id'] ) ); +ct_scan_maintenance_locked(); +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $early2['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_test_exists( $early2 ), 'A retain_until earlier than the date derived from the row deleted the evidence.' ); +// The creation floor alone: a record created long after its acceptance date is not deleted on that date. +$late_created = ct_test_aged_job( 'ret-late-created.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-1 day' ) ), array( 'job_id' => $late_created['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_test_exists( $late_created ), 'A record created after its stated acceptance date was deleted on that date.' ); +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '+1 year' ) ), array( 'job_id' => $early['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_test_exists( $early ) && ct_scan_row( $early['job_id'] )->retain_until > ct_test_old( '+1 day' ), 'A later stored deletion date was shortened.' ); +// A job that can still run is never deleted even if its dates say it is due. +$running = ct_test_aged_job( 'ret-running.example.net', 'awaiting_confirmation', '-3 years -1 day', null ); +$wpdb->update( $jobs, array( 'closed_at' => ct_test_old( '-1 day' ), 'retain_until' => ct_test_old( '-1 hour' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'confirmation_expires_at' => ct_test_old( '+1 day' ) ), array( 'job_id' => $running['job_id'] ) ); +ct_scan_maintenance_locked(); +$rr = ct_scan_row( $running['job_id'] ); +ct_test_assert( $rr && get_post( $running['request_id'] ) && null === $rr->closed_at && null === $rr->retain_until, 'A job that can still run was deleted, or kept a closure stamp, because its dates said so.' ); +// Reopening clears the closure stamp so the old date cannot delete it later. +$reopen = ct_test_aged_job( 'ret-reopen.example.net', 'failed', '-1 day', '-1 hour' ); +ct_scan_maintenance_locked(); +ct_test_assert( null !== ct_scan_row( $reopen['job_id'] )->retain_until, 'Fixture expected a closed job.' ); +$wpdb->update( $jobs, array( 'status' => 'awaiting_confirmation' ), array( 'job_id' => $reopen['job_id'] ) ); +ct_scan_maintenance_locked(); +$ro = ct_scan_row( $reopen['job_id'] ); +ct_test_assert( null === $ro->closed_at && null === $ro->retain_until, 'Reopening a job left its old closure and deletion date.' ); +// A terminal job with no finished_at is closed now, not left open. +$nofinish = ct_test_aged_job( 'ret-nofinish.example.net', 'cancelled', '-1 day', null ); +ct_scan_maintenance_locked(); +ct_test_assert( ! empty( ct_scan_row( $nofinish['job_id'] )->closed_at ), 'A terminal job without finished_at was never closed.' ); + +// ---- Version rules and period floors ---------------------------------------------------------------------------- +foreach ( array( '2026-01-01-r1' => true, '2026-10-21-r1' => true, '2026-10-21-r2' => true, '2026-10-21-r3' => false, '2026-10-22-r1' => false, '2027-06-01-r3' => false, 'weird' => false, '' => false, '2026-10-21-r2 ' => false, '2020-01-01-r9999' => false ) as $version => $legacy ) { + ct_test_assert( $legacy === ct_scan_is_legacy_version( $version ), "Version '$version' was classified wrongly." ); +} +ct_test_assert( 3 === ct_scan_clamp_years( 1 ) && 3 === ct_scan_clamp_years( 0 ) && 3 === ct_scan_clamp_years( 'x' ) && 3 === ct_scan_clamp_years( -6 ) && 3 === ct_scan_clamp_years( null ) && 6 === ct_scan_clamp_years( 6 ) && 10 === ct_scan_clamp_years( '10' ), 'The three-year floor does not hold.' ); +$old_version = ct_test_aged_job( 'ret-old-version.example.net', 'failed', '-2 years', '-1 day', '2026-01-01-r1' ); +$weird_version = ct_test_aged_job( 'ret-weird-version.example.net', 'failed', '-2 years', '-1 day', 'weird' ); +ct_scan_maintenance_locked(); +$ov = ct_scan_row( $old_version['job_id'] ); +$wv = ct_scan_row( $weird_version['job_id'] ); +ct_test_assert( $ov->retain_until === ( new DateTimeImmutable( $ov->terms_accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A pre-r2 acceptance is not kept three years from acceptance.' ); +ct_test_assert( $wv->retain_until === ( new DateTimeImmutable( $wv->closed_at, new DateTimeZone( 'UTC' ) ) )->modify( '+6 years' )->format( 'Y-m-d H:i:s' ), 'An unrecognised version did not get the longer period.' ); + +// ---- Deleting in any post status; bounded work per run ---------------------------------------------------------- +$trashed = ct_test_aged_job( 'ret-trashed.example.net', 'failed', '-3 years -1 day', '-1 day' ); +wp_update_post( array( 'ID' => $trashed['request_id'], 'post_status' => 'trash' ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $trashed ) && ! get_post( $trashed['request_id'] ), 'A trashed request (with the contact details and contract) outlived its deleted job.' ); +$batch = array(); +for ( $i = 0; $i < 53; $i++ ) { + $batch[] = ct_test_aged_job( "ret-batch-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); +} +ct_scan_maintenance_locked(); +$remaining = count( array_filter( $batch, 'ct_test_exists' ) ); +ct_test_assert( $remaining >= 3 && $remaining < 53, "A single run did not bound its deletions ($remaining of 53 remain)." ); +ct_scan_maintenance_locked(); +ct_scan_maintenance_locked(); +ct_test_assert( 0 === count( array_filter( $batch, 'ct_test_exists' ) ), 'The remaining due jobs were not deleted on later runs.' ); + // Release works; a damaged hold (purpose without expiry) preserves rather than deletes. $rel = ct_test_aged_job( 'ret-release.example.net', 'failed', '-3 years -1 day', '-1 day' ); ct_scan_admin_set_hold( $rel['job_id'], 'Preserving records for dispute number 456.', 10 ); ct_test_assert( true === ct_scan_admin_release_hold( $rel['job_id'] ), 'Release failed.' ); -ct_test_assert( null === ct_scan_row( $rel['job_id'] )->hold_reason && null === ct_scan_row( $rel['job_id'] )->hold_until, 'Release left the hold in place.' ); +$released = ct_scan_row( $rel['job_id'] ); +ct_test_assert( null === $released->hold_reason && null === $released->hold_until && 1 === (int) $released->hold_released_by && ! empty( $released->hold_released_at ), 'Release left the hold in place or did not record who released it.' ); +ct_test_assert( 'hold_unavailable' === ct_scan_admin_release_hold( $rel['job_id'] )->get_error_code() && 'hold_unavailable' === ct_scan_admin_release_hold( 999999999 )->get_error_code() && 'hold_unavailable' === ct_scan_admin_release_hold( 0 )->get_error_code(), 'Releasing a hold that does not exist reported success.' ); $damaged = ct_test_aged_job( 'ret-damaged.example.net', 'failed', '-3 years -1 day', '-1 day' ); $wpdb->update( $jobs, array( 'hold_reason' => 'Purpose without an expiry (damaged).', 'hold_until' => null ), array( 'job_id' => $damaged['job_id'] ) ); ct_scan_maintenance_locked(); From 1f293eb7782082a381d4ec1df60db4ec26fa197c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 22 Oct 2026 08:46:58 +0000 Subject: [PATCH 3/9] Retention: fix legacy-job floor, starvation, identity backfill, damaged-hold findings, far-future dates Verification review of the previous fixes found: the three-year creation floor made every real r2 job (created moments after its acceptance) permanently undeletable and 50 of them could stall all deletion; restored requests of jobs that predate the identity were never re-deleted; the duplicate-event path did not set the identity meta; a hold with a zero expiry protected evidence but not findings; step 2 and the tombstone post loop could starve; five-digit years defeated the string comparisons; a re-hold kept the old release audit. The floor now allows a one-day gap, doubtful rows are re-examined at most daily, identities are backfilled (adopting what the request carries), the post loop is a single SQL join on the identity excluding live ids, years above 2200 are rejected. Co-Authored-By: Claude Code --- docs/WORDPRESS.md | 10 ++- integrations/wordpress/ct-scan.php | 78 +++++++++++++----- integrations/wordpress/tests/retention.php | 93 +++++++++++++++++++++- 3 files changed, 156 insertions(+), 25 deletions(-) diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 493e8ac..046d2da 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -460,9 +460,11 @@ maintenance (which runs on every worker claim and on the admin queue page): `Y-m-d H:i:s` date no earlier than 2020 (zero dates and other restore debris are refused), the stored deletion date to be no earlier than the one derived from the row today and no earlier than three years after the job's creation, - and the job to be in a terminal state. A job whose base date cannot be trusted + (less a day) and the job to be in a terminal state. A job whose base date cannot be trusted keeps `retain_until` empty and is shown as "unknown (kept)" in the admin queue. - Each run deletes at most 50 jobs, so the queue lock is held briefly. + Each run examines at most 50 due jobs, and a job judged doubtful is looked at + again at most daily (`retention_checked_at`), so doubtful rows cannot hide due + ones and the queue lock is held briefly. - **Narrow preservation hold** (admin queue): one job, a stated purpose of 20 to 190 characters, a mandatory expiry of at most 730 days, recorded with who set it and when (and who released it). While it stands nothing about that job is @@ -476,8 +478,8 @@ maintenance (which runs on every worker claim and on the admin queue page): matches on its ids, scope and creation time; a restored request post matches only on its opaque identity and only when no job row currently uses that request or job id (auto-increment ids can be recycled after a restore and must never - reach a live record). Jobs created before the identity existed are recognised by - their job row only. Tombstones are kept 120 days, longer than the 90-day backup + reach a live record). Jobs created before the identity existed are given one + (adopting what their request already carries) by the next maintenance run. Tombstones are kept 120 days, longer than the 90-day backup rotation, then purged. - Deletion scrubs the contact fields first, then deletes the private request and the job row; if any step fails the record stays (and is retried) rather than diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 097e4c3..0637e3a 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -219,6 +219,7 @@ function ct_scan_maybe_install_schema() { hold_released_by bigint(20) unsigned NULL, hold_released_at datetime NULL, identity char(32) NULL, + retention_checked_at datetime NULL, PRIMARY KEY (job_id), UNIQUE KEY request_id (request_id), KEY status_created (status,created_at), @@ -253,7 +254,7 @@ function ct_scan_maybe_install_schema() { ) {$charset};"; dbDelta( $sql ); $required = array( - $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence', 'closed_at', 'retain_until', 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at', 'hold_released_by', 'hold_released_at', 'identity' ), + $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence', 'closed_at', 'retain_until', 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at', 'hold_released_by', 'hold_released_at', 'identity', 'retention_checked_at' ), $nonces => array( 'nonce_hash', 'expires_at' ), $tombstones => array( 'tombstone_id', 'job_id', 'request_id', 'scope_hash', 'created_at', 'deleted_at', 'reason', 'identity' ), ); @@ -589,6 +590,9 @@ function ct_scan_on_request_ready( $request_id, $fields ) { if ( $existing ) { update_post_meta( $request_id, '_ct_scan_event_recorded', '1' ); update_post_meta( $request_id, '_ct_scan_job_id', (int) $existing->job_id ); + if ( ! empty( $existing->identity ) ) { + update_post_meta( $request_id, '_ct_scan_identity', $existing->identity ); + } update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $existing->authorization_expires_at ); ct_scan_mark_request_status( $request_id, $existing->status, $existing->error_code ? $existing->error_code : '' ); return array( 'job_id' => (int) $existing->job_id, 'status' => $existing->status, 'confirmation_email_status' => $existing->confirmation_email_status, 'duplicate' => true ); @@ -646,6 +650,9 @@ function ct_scan_on_request_ready( $request_id, $fields ) { if ( $existing ) { update_post_meta( $request_id, '_ct_scan_event_recorded', '1' ); update_post_meta( $request_id, '_ct_scan_job_id', (int) $existing->job_id ); + if ( ! empty( $existing->identity ) ) { + update_post_meta( $request_id, '_ct_scan_identity', $existing->identity ); + } update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $existing->authorization_expires_at ); ct_scan_mark_request_status( $request_id, $existing->status, $existing->error_code ? $existing->error_code : '' ); return array( 'job_id' => (int) $existing->job_id, 'status' => $existing->status, 'confirmation_email_status' => $existing->confirmation_email_status, 'duplicate' => true ); @@ -798,7 +805,7 @@ function ct_scan_terminal_sql() { * into year -1, and other restore/migration debris are refused rather than guessed at). */ function ct_scan_valid_datetime( $value ) { - if ( ! is_string( $value ) || ! preg_match( '/\A(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\z/', $value, $m ) || (int) $m[1] < 2020 ) { + if ( ! is_string( $value ) || ! preg_match( '/\A(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\z/', $value, $m ) || (int) $m[1] < 2020 || (int) $m[1] > 2200 ) { return false; } if ( ! checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) || (int) $m[4] > 23 || (int) $m[5] > 59 || (int) $m[6] > 59 ) { @@ -872,8 +879,8 @@ function ct_scan_job_on_hold( $row, $now ) { /** * May this job's records be deleted now? Every date involved must be trustworthy, the stored date must - * not be earlier than the one derived from the row today or than three years after creation, and no hold - * may apply. Anything doubtful is kept. + * not be earlier than the one derived from the row today or than three years (less a day) after creation, + * and no hold may apply. Anything doubtful is kept. */ function ct_scan_deletion_allowed( $row, $now ) { if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) || ct_scan_job_on_hold( $row, $now ) ) { @@ -885,10 +892,12 @@ function ct_scan_deletion_allowed( $row, $now ) { } } $expected = ct_scan_retain_until( $row ); - if ( false === $expected || $row->retain_until < $expected || $row->retain_until < $row->closed_at ) { + if ( false === $expected || $row->retain_until < $expected ) { return false; } - $floor = ( new DateTimeImmutable( $row->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ); + // A record is created within moments of the acceptance it records; a deletion date more than a day + // short of three years after creation means a date somewhere is wrong, and the record is kept. + $floor = ( new DateTimeImmutable( $row->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years -1 day' )->format( 'Y-m-d H:i:s' ); return $row->retain_until >= $floor && $row->retain_until <= $now; } @@ -950,31 +959,55 @@ function ct_scan_retention_locked( $now ) { $wpdb->query( "UPDATE {$table} SET closed_at = NULL, retain_until = NULL WHERE status NOT IN ({$terminal}) AND (closed_at IS NOT NULL OR retain_until IS NOT NULL)" ); // 1. Closure is explicit: the first time a job is seen terminal it is stamped. $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET closed_at = COALESCE(finished_at, %s) WHERE closed_at IS NULL AND status IN ({$terminal})", $now ) ); + // A row judged doubtful is re-examined at most daily, so doubtful rows can never starve the rest. + $recheck = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-1 day' )->format( 'Y-m-d H:i:s' ); + // 1b. Jobs that predate the opaque identity get one (and so does their request) so a restored request + // can be recognised after deletion. + $anonymous = $wpdb->get_results( "SELECT job_id,request_id FROM {$table} WHERE identity IS NULL ORDER BY job_id ASC LIMIT 100" ); + foreach ( (array) $anonymous as $row ) { + // Adopt an identity the request already carries; otherwise mint one for both. + $carried = ct_scan_meta_string( (int) $row->request_id, '_ct_scan_identity' ); + $identity = 1 === preg_match( '/\A[a-f0-9]{32}\z/', $carried ) ? $carried : bin2hex( random_bytes( 16 ) ); + if ( 1 === $wpdb->update( $table, array( 'identity' => $identity ), array( 'job_id' => (int) $row->job_id, 'identity' => null ), array( '%s' ), array( '%d', '%s' ) ) && $identity !== $carried ) { + $post = get_post( (int) $row->request_id ); + if ( $post && 'ct_promo_request' === $post->post_type ) { + update_post_meta( (int) $row->request_id, '_ct_scan_identity', $identity ); + } + } + } // 2. The evidence deletion date is fixed once, at closure. An untrustworthy base leaves it NULL (kept). - $unset = $wpdb->get_results( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL LIMIT 200" ); + $unset = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY job_id ASC LIMIT 200", $recheck ) ); foreach ( (array) $unset as $row ) { $when = ct_scan_retain_until( $row ); if ( false !== $when ) { $wpdb->update( $table, array( 'retain_until' => $when ), array( 'job_id' => (int) $row->job_id, 'retain_until' => null ), array( '%s' ), array( '%d', '%s' ) ); + } else { + $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); } } // 3. Findings: twelve months after closure (366 days, so a leap day cannot make it early), unless held. $findings_cutoff = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-366 days' )->format( 'Y-m-d H:i:s' ); $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); $wpdb->query( $wpdb->prepare( - "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND closed_at IS NOT NULL AND closed_at >= '2020-01-01 00:00:00' AND closed_at < %s AND (hold_reason IS NULL OR hold_until <= %s)", + "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND closed_at IS NOT NULL AND closed_at >= '2020-01-01 00:00:00' AND closed_at < %s AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s))", $findings_cutoff, $grace_edge ) ); // 4. Evidence: delete what has outlived its retain_until, except under a hold. Bounded per run. $due = $wpdb->get_results( $wpdb->prepare( - "SELECT * FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND (hold_reason IS NULL OR hold_until <= %s) ORDER BY retain_until ASC LIMIT 50", + "SELECT * FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s)) AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY retain_until ASC LIMIT 50", $now, - $grace_edge + $grace_edge, + $recheck ) ); foreach ( (array) $due as $row ) { if ( ct_scan_deletion_allowed( $row, $now ) ) { - ct_scan_delete_job_records( $row, 'retention' ); + if ( ! ct_scan_delete_job_records( $row, 'retention' ) ) { + $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); + } + } else { + // Doubtful: keep it, and look at it again tomorrow rather than on every run. + $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); } } // 5. A restored backup must not resurrect deleted records: delete matches again. @@ -998,15 +1031,20 @@ function ct_scan_apply_tombstones( $now ) { ct_scan_delete_job_records( $row, 'restored' ); } } - // A restored request post whose job row was not restored (or was restored without matching). - $posts = $wpdb->posts; - $restored = $wpdb->get_results( 'SELECT t.job_id, t.request_id, t.identity FROM ' . $stones . ' t INNER JOIN ' . $posts . " p ON p.ID = t.request_id AND p.post_type = 'ct_promo_request' WHERE t.identity IS NOT NULL AND t.identity <> '' ORDER BY t.tombstone_id DESC LIMIT 50" ); + // A restored request post whose job row was not restored: matched in SQL on the opaque identity + // (never on recyclable numeric ids alone), and only where no job row currently uses that request or + // job id, so a live record can never be reached. + $postmeta = $wpdb->postmeta; + $restored = $wpdb->get_results( + 'SELECT t.job_id, t.request_id FROM ' . $stones . ' t' + . ' INNER JOIN ' . $wpdb->posts . " p ON p.ID = t.request_id AND p.post_type = 'ct_promo_request'" + . ' INNER JOIN ' . $postmeta . " m ON m.post_id = t.request_id AND m.meta_key = '_ct_scan_identity' AND m.meta_value = t.identity" + . " WHERE t.identity IS NOT NULL AND t.identity <> ''" + . ' AND NOT EXISTS (SELECT 1 FROM ' . $table . ' j WHERE j.request_id = t.request_id OR j.job_id = t.job_id)' + . ' ORDER BY t.tombstone_id DESC LIMIT 50' + ); foreach ( (array) $restored as $stone ) { - $live = $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$table} WHERE request_id = %d OR job_id = %d LIMIT 1", (int) $stone->request_id, (int) $stone->job_id ) ); - if ( $live ) { - continue; - } - if ( hash_equals( (string) $stone->identity, ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_identity' ) ) && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { + if ( (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { wp_delete_post( (int) $stone->request_id, true ); } } @@ -1032,7 +1070,7 @@ function ct_scan_admin_set_hold( $job_id, $reason, $days ) { return new WP_Error( 'hold_unavailable', 'This job no longer exists.' ); } $until = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '+' . $days . ' days' )->format( 'Y-m-d H:i:s' ); - $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_reason' => $reason, 'hold_until' => $until, 'hold_set_by' => (int) $user->ID, 'hold_set_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%d', '%s' ), array( '%d' ) ); + $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_reason' => $reason, 'hold_until' => $until, 'hold_set_by' => (int) $user->ID, 'hold_set_at' => ct_scan_utc_now(), 'hold_released_by' => null, 'hold_released_at' => null ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%d', '%s', '%d', '%s' ), array( '%d' ) ); if ( false === $updated ) { return new WP_Error( 'hold_write_failed', 'The hold could not be recorded.' ); } diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 2d30ec1..7c8f50b 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -24,7 +24,7 @@ function ct_test_aged_job( $host, $status, $accepted, $closed, $version = '2026- $request_ids[] = $request_id; $event = ct_scan_on_request_ready( $request_id, $fields ); ct_test_assert( is_array( $event ), 'Retention fixture job was not created.' ); - $update = array( 'status' => $status, 'terms_accepted_at' => ct_test_old( $accepted ), 'created_at' => ct_test_old( $accepted ), 'terms_version' => $version, 'confirmation_token_hash' => null ); + $update = array( 'status' => $status, 'terms_accepted_at' => ct_test_old( $accepted ), 'created_at' => ( new DateTimeImmutable( ct_test_old( $accepted ), new DateTimeZone( 'UTC' ) ) )->modify( '+30 seconds' )->format( 'Y-m-d H:i:s' ), 'terms_version' => $version, 'confirmation_token_hash' => null ); if ( null !== $closed ) { $update['finished_at'] = ct_test_old( $closed ); $update['result_json'] = '{"fixture":true}'; @@ -278,6 +278,97 @@ function ct_test_exists( $job ) { wp_set_current_user( 0 ); ct_test_assert( 'hold_forbidden' === ct_scan_admin_release_hold( $damaged['job_id'] )->get_error_code() && ct_test_exists( $damaged ) && ! empty( ct_scan_row( $damaged['job_id'] )->hold_reason ), 'An anonymous caller released a hold.' ); + +// ---- Second review: no starvation, identity backfill, damaged holds, far-future dates --------------------------- +// Doubtful rows (a deletion date a year short of the floor) are judged once a day and cannot hide a due job. +$doubtful = array(); +for ( $i = 0; $i < 52; $i++ ) { + $d = ct_test_aged_job( "ret-doubtful-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); + $wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-1 day' ) ), array( 'job_id' => $d['job_id'] ) ); + $doubtful[] = $d; +} +$due_behind = ct_test_aged_job( 'ret-behind-doubtful.example.net', 'failed', '-3 years -2 days', '-2 days' ); +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 year' ) ), array( 'job_id' => $due_behind['job_id'] ) ); // sorts first, but is itself doubtful +// Due only after all the doubtful rows in date order (so a 50-row window would never reach it). +$really_due = ct_test_aged_job( 'ret-really-due.example.net', 'failed', '-3 years -2 hours', '-1 hour' ); +for ( $run = 0; $run < 3; $run++ ) { + ct_scan_maintenance_locked(); +} +ct_test_assert( ! ct_test_exists( $really_due ), 'Doubtful rows starved a due job.' ); +ct_test_assert( 52 === count( array_filter( $doubtful, 'ct_test_exists' ) ) && ct_test_exists( $due_behind ), 'Doubtful rows were deleted.' ); +ct_test_assert( ! empty( ct_scan_row( $doubtful[0]['job_id'] )->retention_checked_at ), 'A doubtful row was not marked as examined.' ); +foreach ( array_merge( $doubtful, array( $due_behind ) ) as $d ) { + $wpdb->delete( $jobs, array( 'job_id' => $d['job_id'] ) ); + wp_delete_post( $d['request_id'], true ); +} + +// A job from before the identity existed gets one (adopting what its request carries), so a restore can be recognised. +$legacy = ct_test_aged_job( 'ret-preidentity.example.net', 'failed', '-3 years -2 days', '-2 days' ); +$wpdb->update( $jobs, array( 'identity' => null, 'retain_until' => ct_test_old( '+1 day' ) ), array( 'job_id' => $legacy['job_id'] ) ); +delete_post_meta( $legacy['request_id'], '_ct_scan_identity' ); +ct_scan_maintenance_locked(); +$identity = ct_scan_row( $legacy['job_id'] )->identity; +ct_test_assert( 1 === preg_match( '/\A[a-f0-9]{32}\z/', (string) $identity ) && $identity === ct_scan_meta_string( $legacy['request_id'], '_ct_scan_identity' ), 'A job without an identity was not given one together with its request.' ); +$carried = ct_test_aged_job( 'ret-carried.example.net', 'failed', '-3 years -2 days', '-2 days' ); +$wpdb->update( $jobs, array( 'identity' => null, 'retain_until' => ct_test_old( '+1 day' ) ), array( 'job_id' => $carried['job_id'] ) ); +update_post_meta( $carried['request_id'], '_ct_scan_identity', str_repeat( 'a', 32 ) ); +ct_scan_maintenance_locked(); +ct_test_assert( str_repeat( 'a', 32 ) === ct_scan_row( $carried['job_id'] )->identity && str_repeat( 'a', 32 ) === ct_scan_meta_string( $carried['request_id'], '_ct_scan_identity' ), 'An identity the request already carried was replaced.' ); +// Then delete it and restore the post: it is recognised and deleted again. +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 hour' ) ), array( 'job_id' => $legacy['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $legacy ) && ! get_post( $legacy['request_id'] ), 'Fixture expected the pre-identity job to be deleted.' ); +wp_insert_post( array( 'import_id' => $legacy['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); +update_post_meta( $legacy['request_id'], '_ct_scan_job_id', $legacy['job_id'] ); +update_post_meta( $legacy['request_id'], '_ct_scan_identity', $identity ); +ct_scan_maintenance_locked(); +ct_test_assert( ! get_post( $legacy['request_id'] ), 'The restored request of a pre-identity job was not deleted again.' ); +// The duplicate-event path restores the identity meta of a request that lost it. +list( $dup_id, $dup_fields ) = ct_test_request( 'ret-dup.example.net' ); +$request_ids[] = $dup_id; +$dup_event = ct_scan_on_request_ready( $dup_id, $dup_fields ); +$dup_identity = ct_scan_row( $dup_event['job_id'] )->identity; +ct_test_assert( 1 === preg_match( '/\A[a-f0-9]{32}\z/', (string) $dup_identity ) && $dup_identity === ct_scan_meta_string( $dup_id, '_ct_scan_identity' ), 'A new job does not carry its identity on the request.' ); +delete_post_meta( $dup_id, '_ct_scan_identity' ); +delete_post_meta( $dup_id, '_ct_scan_event_recorded' ); +ct_scan_on_request_ready( $dup_id, $dup_fields ); +ct_test_assert( $dup_identity === ct_scan_meta_string( $dup_id, '_ct_scan_identity' ), 'The duplicate-event path left the request without its identity.' ); + +// A damaged hold (zero expiry) also protects the findings. +$zero_hold = ct_test_aged_job( 'ret-zero-hold.example.net', 'completed', '-2 years', '-13 months' ); +$wpdb->update( $jobs, array( 'hold_reason' => 'Preserving for a dispute (damaged expiry).', 'hold_until' => '0000-00-00 00:00:00' ), array( 'job_id' => $zero_hold['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( null !== ct_scan_row( $zero_hold['job_id'] )->result_json, 'A hold with a zero expiry did not protect the findings.' ); + +// A far-future creation date does not defeat the creation floor. +$future = ct_test_aged_job( 'ret-future.example.net', 'failed', '-3 years -2 days', '-2 days' ); +$wpdb->update( $jobs, array( 'created_at' => '9999-01-01 00:00:00' ), array( 'job_id' => $future['job_id'] ) ); +ct_scan_maintenance_locked(); +ct_test_assert( ct_test_exists( $future ) && ! ct_scan_valid_datetime( '9999-01-01 00:00:00' ) && ! ct_scan_valid_datetime( '10000-01-01 00:00:00' ), 'A far-future date was accepted.' ); + +// A restored request hidden behind many newer tombstones that point at live records is still found. +$buried = ct_test_aged_job( 'ret-buried.example.net', 'failed', '-3 years -2 days', '-2 days' ); +ct_scan_maintenance_locked(); +ct_test_assert( ! ct_test_exists( $buried ), 'Fixture expected the buried job to be deleted.' ); +$buried_stone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $buried['job_id'] ) ); +for ( $i = 0; $i < 55; $i++ ) { + $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 770000000 + $i, 'request_id' => 780000000 + $i, 'scope_hash' => str_repeat( 'd', 64 ), 'created_at' => ct_test_old( '-1 hour' ), 'deleted_at' => ct_test_old( '-1 minute' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); +} +wp_insert_post( array( 'import_id' => $buried['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); +update_post_meta( $buried['request_id'], '_ct_scan_job_id', $buried['job_id'] ); +update_post_meta( $buried['request_id'], '_ct_scan_identity', $buried_stone->identity ); +ct_scan_maintenance_locked(); +ct_test_assert( ! get_post( $buried['request_id'] ), 'Newer tombstones hid a restored request from cleanup.' ); + +wp_set_current_user( 1 ); +// Placing a new hold clears the audit of an earlier release. +$reheld = ct_test_aged_job( 'ret-reheld.example.net', 'failed', '-1 day', '-1 hour' ); +ct_scan_admin_set_hold( $reheld['job_id'], 'First preservation purpose for this job.', 10 ); +ct_scan_admin_release_hold( $reheld['job_id'] ); +ct_scan_admin_set_hold( $reheld['job_id'], 'Second preservation purpose for this job.', 10 ); +$rh = ct_scan_row( $reheld['job_id'] ); +ct_test_assert( null === $rh->hold_released_by && null === $rh->hold_released_at && 'Second preservation purpose for this job.' === $rh->hold_reason, 'A new hold kept the audit of an earlier release.' ); + // Deletion that cannot complete leaves the record (and its tombstone) rather than half-deleting. $stuck = ct_test_aged_job( 'ret-stuck.example.net', 'failed', '-3 years -1 day', '-1 day' ); $block = static function ( $check, $post ) use ( $stuck ) { From 34708ff0ce3bf08623f672efee2861206d327ad8 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Fri, 23 Oct 2026 15:44:52 -0400 Subject: [PATCH 4/9] Retention: extend instead of keeping forever; grace after release; clock skew; shared budget Review fixes for the retention mechanics (data deletion; every change keeps data longer, never shorter): - A stored retain_until earlier than the date required today (the period derived under the current configuration, never less than three years after creation) is extended to that date under the queue lock (compare-and-set) instead of making the job undeletable forever; the job is deleted when the later date arrives. Raising evidence/legacy retention after closure is honoured; lowering it never shortens a stored date. - A legacy acceptance dated more than a day before its record was created gets three full years from creation: later than the promise, and finite. - Releasing a hold ends it now (purpose kept, releaser recorded) so the documented three-day grace applies; releasing an expired hold never lengthens it. - Deletions stop while the PHP and database clocks disagree by more than five minutes; the clock problem is logged once per occurrence, not every run. - One budget of 50 deletion attempts per run, shared by the evidence step and both tombstone loops; a refused restored-post deletion is counted and skipped. - A restored job row's post is deleted only if it carries the tombstone's identity (recycled post ids); a due job whose post carries another identity is kept whole. - Retention on the admin path catches exceptions like the claim path; on the claim path it runs only on polls that hand out no job. - Tests: config raised after closure (extended, then deleted at the new date, not shortened when lowered), legacy accepted-before-created deadline, release grace, clock skew (both directions) and log-once, shared budget, recycled post ids, foreign identity, leap-day bases, claim latency. Docs updated. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/WORDPRESS.md | 63 +++-- integrations/wordpress/ct-scan.php | 243 ++++++++++++++---- .../wordpress/tests/retention-maintain.php | 5 + integrations/wordpress/tests/retention.php | 180 ++++++++++++- integrations/wordpress/tests/support.php | 2 + 5 files changed, 413 insertions(+), 80 deletions(-) create mode 100644 integrations/wordpress/tests/retention-maintain.php diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index d7691d8..be899ba 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -477,7 +477,8 @@ evidence must remain available. A started job with a missing receipt needs operator investigation, not a blind retry. Retention follows the owner's decisions of 2026-10-22, implemented in -maintenance (which runs on every worker claim and on the admin queue page): +maintenance (which runs on worker polls that hand out no job and on the admin +queue page): - **Closure is explicit.** The first time a job is seen in a terminal state (`completed`, `failed`, `failed_unknown`, `cancelled`, `expired`, `blocked`) @@ -489,35 +490,58 @@ maintenance (which runs on every worker claim and on the admin queue page): (366 days, so a leap day cannot make it early) after closure. The signed evidence stays. - **Signed authorization evidence** (the job row and its linked private request, - in whatever post status it is in): deleted at `retain_until`, fixed once at - closure: **six years after closure**, except that r2 and earlier acceptances + in whatever post status it is in): deleted at `retain_until`, set at closure + and afterwards only ever extended, never shortened: **six years after + closure**, except that r2 and earlier acceptances (versions `YYYY-MM-DD-rN` up to `2026-10-21-r2`) keep their promised **three years from acceptance**. Never less than three years, whatever the configuration; a version that cannot be parsed gets the longer period. The configured `evidence_retention_years` is floored at **six** for non-legacy versions: a word, null, a boolean, 0 or a value below six means six, never less. +- **`retain_until` can be extended, never shortened.** When a job's stored date + comes due, it is compared with the date required today: the period derived + from the row under the current configuration, and never less than three years + after the job's creation. If the stored date is earlier (for example, + `evidence_retention_years` or `legacy_retention_years` was raised after + closure), it is extended to the required date (compare-and-set, under the + queue lock) and the job is deleted only when that later date arrives. Lowering + the configuration never moves a stored date earlier. A displayed date can + therefore grow when it comes due after a configuration change. +- **Legacy acceptance dated before its record.** A record is created within + moments of the acceptance it records. If an r2-or-earlier acceptance is dated + more than a day before the job's creation, the stated acceptance date and the + plugin's own creation stamp disagree. The job then gets three full years from + creation. That is later than the promised three years from acceptance, and it + is a definite date rather than "never". - **Doubtful data is kept.** Deletion requires every date involved to be a real `Y-m-d H:i:s` date no earlier than 2020 (zero dates and other restore debris - are refused), the stored deletion date to be no earlier than the one derived - from the row today and no earlier than three years after the job's creation, - (less a day) and the job to be in a terminal state. A job whose base date cannot be trusted - keeps `retain_until` empty and is shown as "unknown (kept)" in the admin queue. - A record that appears to have closed before it was created is kept. Nothing is - deleted while the server clock is earlier than a stored job timestamp (a clock - jumped backwards, a bad restore): the run logs once and only the - non-destructive steps proceed. Each run examines at most 50 due jobs, and a job judged doubtful is looked at - again at most daily (`retention_checked_at`), so doubtful rows cannot hide due - ones and the queue lock is held briefly. + are refused), the stored deletion date to have arrived and to be no earlier + than the date required today (see above), and the job to be in a terminal + state. A job whose base date cannot be trusted keeps `retain_until` empty and + is shown as "unknown (kept)" in the admin queue. A record that appears to + have closed before it was created is kept. Nothing is deleted while the PHP + clock and the database clock (`UTC_TIMESTAMP()`) disagree by more than five + minutes, or while the server clock is earlier than a stored job timestamp (a + clock jumped backwards, a bad restore). The problem is logged once (not on + every run) until the clocks agree again, and only the non-destructive steps + proceed. A forward jump of both clocks together cannot be detected. Each run + makes at most 50 deletion attempts in total, shared by the evidence step and + the tombstone clean-up. A job judged doubtful is looked at again at most daily + (`retention_checked_at`), so doubtful rows cannot hide due ones, and the queue + lock is held briefly. - **Narrow preservation hold** (admin queue): one job, a stated purpose of 20 to 190 characters, a mandatory expiry of at most 730 days, recorded with who set it and when (and who released it). While it stands nothing about that job is cleared or deleted, including by a tombstone; after it expires or is released, - the job stays preserved for a further three days. A damaged hold (purpose + the job stays preserved for a further three days. Releasing ends the hold at + that moment (an already expired hold keeps its own end). The stated purpose is + kept, and who released it and when are recorded. A damaged hold (purpose without an expiry) preserves rather than deletes. - **Retention runs outside the queue lock.** Only one retention run at a time - (its own advisory lock, throttled to about once a minute on the worker's poll - and run again on the admin queue page). The non-destructive steps are single + (its own advisory lock, throttled to about once a minute on worker polls that + hand out no job, so a lease is never delayed by it, and run again on the admin + queue page; a failure is logged and never breaks the poll or the page). The non-destructive steps are single statements; every deletion takes the queue lock for that one job only, without waiting (a busy queue means "next run"), re-reads the row under it and re-checks every condition, so Stop (which waits up to three seconds for that @@ -526,7 +550,9 @@ maintenance (which runs on every worker claim and on the admin queue page): request id, a keyed MAC of the scope, creation time, an opaque random identity, time and reason; no personal or customer data) before the deletion. If a restored backup brings the rows back, the next maintenance run deletes them again: a job row - matches on its ids, scope and creation time; a restored request post matches + matches on its ids, scope and creation time, and its request post is deleted + with it only if the post carries the tombstone's opaque identity (otherwise + the post id was recycled and belongs to another record); a restored request post matches only on its opaque identity and only when no job row currently uses that request or job id (auto-increment ids can be recycled after a restore and must never reach a live record). Jobs created before the identity existed are given one @@ -534,7 +560,8 @@ maintenance (which runs on every worker claim and on the admin queue page): rotation, then purged. - Deletion scrubs the contact fields first, then deletes the private request and the job row; if any step fails the record stays (and is retried) rather than - being half-deleted. + being half-deleted. A due job whose linked request carries another record's + identity is not touched at all. Not covered here: backup rotation itself, the contract archive's historical Git objects and clones (issue 11), unsigned caller leads (there is no lead record in diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index d2f3f2a..3025d13 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -942,31 +942,64 @@ function ct_scan_job_on_hold( $row, $now ) { } /** - * May this job's records be deleted now? Every date involved must be trustworthy, the stored date must - * not be earlier than the one derived from the row today or than three years (less a day) after creation, - * and no hold may apply. Anything doubtful is kept. + * The earliest date this job's evidence may be deleted, judged from the row today: the period derived from + * the row under today's configuration (so a longer period configured after closure is honoured), and never + * less than three years after the record's creation. False if a base date cannot be trusted. + * + * A record is created within moments of the acceptance it records, so a derived date within a day of + * "creation + 3 years" is accepted as it is. A derived date more than a day short of that (in practice: an + * r2-or-earlier acceptance dated more than a day before its record was created, the stated acceptance date + * and the plugin's own creation stamp disagreeing) is replaced by three full years from creation. That date + * is later than the promised three years from acceptance, and it is a definite date rather than "never". */ -function ct_scan_deletion_allowed( $row, $now ) { - if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) || ct_scan_job_on_hold( $row, $now ) ) { +function ct_scan_required_retain_until( $row ) { + $derived = ct_scan_retain_until( $row ); + if ( false === $derived || ! ct_scan_valid_datetime( $row->created_at ) ) { + return false; + } + try { + $created = new DateTimeImmutable( $row->created_at, new DateTimeZone( 'UTC' ) ); + if ( $derived >= $created->modify( '+3 years -1 day' )->format( 'Y-m-d H:i:s' ) ) { + return $derived; + } + return max( $derived, $created->modify( '+3 years' )->format( 'Y-m-d H:i:s' ) ); + } catch ( Throwable $error ) { return false; } +} + +/** + * What retention may do with this job now: array( 'keep' | 'extend' | 'delete', $date ). Every date involved + * must be trustworthy, the job terminal and not held, or it is kept. A stored deletion date earlier than the + * one required today (ct_scan_required_retain_until()) is never honoured: it is EXTENDED to the required date + * ($date), and the job is deleted only when that later date arrives. A stored date is never shortened. + */ +function ct_scan_deletion_decision( $row, $now ) { + if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) || ct_scan_job_on_hold( $row, $now ) ) { + return array( 'keep', null ); + } foreach ( array( $row->created_at, $row->closed_at, $row->retain_until ) as $date ) { if ( ! ct_scan_valid_datetime( $date ) ) { - return false; + return array( 'keep', null ); } } // A record cannot have closed before it was created; if it appears to, a date is wrong. if ( $row->closed_at < $row->created_at ) { - return false; + return array( 'keep', null ); } - $expected = ct_scan_retain_until( $row ); - if ( false === $expected || $row->retain_until < $expected ) { - return false; + $required = ct_scan_required_retain_until( $row ); + if ( false === $required ) { + return array( 'keep', null ); } - // A record is created within moments of the acceptance it records; a deletion date more than a day - // short of three years after creation means a date somewhere is wrong, and the record is kept. - $floor = ( new DateTimeImmutable( $row->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years -1 day' )->format( 'Y-m-d H:i:s' ); - return $row->retain_until >= $floor && $row->retain_until <= $now; + if ( $row->retain_until < $required ) { + return array( 'extend', $required ); + } + return array( $row->retain_until <= $now ? 'delete' : 'keep', null ); +} + +/** May this job's records be deleted now? Only when ct_scan_deletion_decision() says so; anything doubtful is kept. */ +function ct_scan_deletion_allowed( $row, $now ) { + return 'delete' === ct_scan_deletion_decision( $row, $now )[0]; } /** @@ -993,14 +1026,33 @@ function ct_scan_record_tombstone( $row, $reason ) { return false !== $result; } -/** Delete one job and its linked request, whatever status that post is in. True only if both are gone and a tombstone exists. */ -function ct_scan_delete_job_records( $row, $reason ) { +/** + * Delete one job and its linked request, whatever status that post is in. True only if the job row is gone + * and a tombstone exists. + * + * The request post is deleted only when it provably belongs to this job (post ids can be recycled after a + * restore). For a retention deletion, a post that carries a different opaque identity than the job keeps + * everything (nothing is deleted; retried later). For a restored job row recognised by its tombstone + * ($tombstone_identity given), the post is deleted only if it carries exactly that identity; otherwise the + * post is another record and is left alone while the restored row itself is deleted again. + */ +function ct_scan_delete_job_records( $row, $reason, $tombstone_identity = null ) { global $wpdb; $table = ct_scan_jobs_table(); + clean_post_cache( (int) $row->request_id ); + $post = get_post( (int) $row->request_id ); + $ours = $post && 'ct_promo_request' === $post->post_type && (int) $post->ID === (int) $row->request_id; + if ( $ours ) { + $post_identity = ct_scan_meta_string( (int) $post->ID, '_ct_scan_identity' ); + if ( null !== $tombstone_identity ) { + $ours = is_string( $tombstone_identity ) && '' !== $tombstone_identity && hash_equals( $tombstone_identity, $post_identity ); + } elseif ( '' !== $post_identity && is_string( $row->identity ) && '' !== $row->identity && ! hash_equals( $row->identity, $post_identity ) ) { + return false; + } + } if ( ! ct_scan_record_tombstone( $row, $reason ) ) { return false; } - $post = get_post( (int) $row->request_id ); // Scrub personal fields first so a failure part-way never leaves contact data behind a deleted link. $personal_scrubbed = $wpdb->update( $table, array( 'contact_email' => '', 'contact_name' => '' ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s' ), array( '%d' ) ); if ( false === $personal_scrubbed ) { @@ -1014,7 +1066,7 @@ function ct_scan_delete_job_records( $row, $reason ) { } // The request carries the contact details and the full contract copy: delete it in any status // (a trashed or re-published copy must not outlive the job that pointed to it). - if ( $post && 'ct_promo_request' === $post->post_type && (int) $post->ID === (int) $row->request_id ) { + if ( $ours ) { if ( ! wp_delete_post( (int) $row->request_id, true ) ) { return false; } @@ -1061,12 +1113,38 @@ function ct_scan_retention_run( $force = false ) { return true; } +/** Retention from a request path (worker poll, admin page): a failure keeps the records and is logged, never propagated. */ +function ct_scan_retention_run_safely() { + try { + return ct_scan_retention_run(); + } catch ( Throwable $error ) { + error_log( 'ct-scan: a retention run failed (' . get_class( $error ) . '); records are kept and the run is retried later.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return false; + } +} + +/** At most this many deletion attempts per retention run, shared by every deleting step. */ +function ct_scan_retention_batch_limit() { + return 50; +} + +/** The largest disagreement tolerated between the PHP clock and the database clock before deletions stop. */ +function ct_scan_clock_skew_limit_seconds() { + return 300; +} + /** - * Deletion is refused when the server clock looks wrong: if any job carries a (plausible) timestamp later - * than "now", now cannot be trusted to decide that a retention period has passed. + * Deletion is refused when the server clock looks wrong: if the PHP clock and the database clock disagree + * by more than five minutes (one of them jumped, forwards or backwards), or if any job carries a (plausible) + * timestamp later than "now" (a clock that jumped backwards, a bad restore). A forward jump of both clocks + * together cannot be detected here. */ function ct_scan_clock_is_sane( $now ) { global $wpdb; + $db_now = $wpdb->get_var( 'SELECT UTC_TIMESTAMP()' ); + if ( ! is_string( $db_now ) || ! ct_scan_valid_datetime( $db_now ) || abs( strtotime( $db_now . ' UTC' ) - time() ) > ct_scan_clock_skew_limit_seconds() ) { + return false; + } $table = ct_scan_jobs_table(); $limit = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '+5 minutes' )->format( 'Y-m-d H:i:s' ); foreach ( array( 'created_at', 'finished_at', 'closed_at', 'terms_accepted_at' ) as $column ) { @@ -1078,14 +1156,38 @@ function ct_scan_clock_is_sane( $now ) { return $now >= '2020-01-01 00:00:00' && $now < '2200-01-01 00:00:00'; } -/** Delete one job if, under the queue lock and from a fresh read, it is still due. Returns 'deleted'|'kept'|'failed'|'busy'. */ +/** Log a clock that stops deletions once per occurrence (not on every run); forget it once the clock is sane again. */ +function ct_scan_note_clock_state( $sane ) { + $flag = 'ct_scan_retention_clock_warned'; + if ( ! $sane && ! get_option( $flag ) ) { + error_log( 'ct-scan: retention deletions skipped: the server clock disagrees with the database clock or is earlier than a stored job timestamp. Logged once until the clock is consistent again.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + update_option( $flag, ct_scan_utc_now(), false ); + } elseif ( $sane && get_option( $flag ) ) { + delete_option( $flag ); + } +} + +/** + * Act on one job if, under the queue lock and from a fresh read, retention may: delete it, or extend a + * stored deletion date that is earlier than the one required today (compare-and-set, never shorter). + * Returns 'deleted'|'extended'|'kept'|'failed'|'busy'. + */ function ct_scan_delete_if_due( $job_id, $now ) { + global $wpdb; if ( ! ct_scan_lock_acquire() ) { return 'busy'; } try { $row = ct_scan_row( $job_id ); - if ( ! $row || ! ct_scan_deletion_allowed( $row, $now ) ) { + if ( ! $row ) { + return 'kept'; + } + list( $action, $until ) = ct_scan_deletion_decision( $row, $now ); + if ( 'extend' === $action ) { + $updated = $wpdb->update( ct_scan_jobs_table(), array( 'retain_until' => $until, 'retention_checked_at' => null ), array( 'job_id' => (int) $row->job_id, 'retain_until' => $row->retain_until ), array( '%s', '%s' ), array( '%d', '%s' ) ); + return 1 === $updated ? 'extended' : 'kept'; + } + if ( 'delete' !== $action ) { return 'kept'; } return ct_scan_delete_job_records( $row, 'retention' ) ? 'deleted' : 'failed'; @@ -1098,7 +1200,7 @@ function ct_scan_retention_steps( $now ) { global $wpdb; $table = ct_scan_jobs_table(); $terminal = ct_scan_terminal_sql(); - $budget = microtime( true ) + 3.0; + $deadline = microtime( true ) + 3.0; // 0. A job that can run again is not closed: forget any closure stamped on it. $wpdb->query( "UPDATE {$table} SET closed_at = NULL, retain_until = NULL WHERE status NOT IN ({$terminal}) AND (closed_at IS NOT NULL OR retain_until IS NOT NULL)" ); // 1. Closure is explicit: the first time a job is seen terminal it is stamped. @@ -1119,7 +1221,8 @@ function ct_scan_retention_steps( $now ) { } } } - // 2. The evidence deletion date is fixed once, at closure. An untrustworthy base leaves it NULL (kept). + // 2. The evidence deletion date is set at closure; later it may only be extended (step 4), never shortened. + // An untrustworthy base leaves it NULL (kept). $unset = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY job_id ASC LIMIT 200", $recheck ) ); foreach ( (array) $unset as $row ) { $when = ct_scan_retain_until( $row ); @@ -1130,8 +1233,9 @@ function ct_scan_retention_steps( $now ) { } } // Everything below deletes. Refuse all of it if the clock cannot be trusted. - if ( ! ct_scan_clock_is_sane( $now ) ) { - error_log( 'ct-scan: retention deletions skipped: the server clock is earlier than a stored job timestamp.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + $sane = ct_scan_clock_is_sane( $now ); + ct_scan_note_clock_state( $sane ); + if ( ! $sane ) { return; } // 3. Findings: twelve months after closure (366 days, so a leap day cannot make it early), unless held. @@ -1142,7 +1246,9 @@ function ct_scan_retention_steps( $now ) { $findings_cutoff, $grace_edge ) ); - // 4. Evidence: delete what has outlived its retain_until, except under a hold. Bounded per run. + // 4. Evidence: delete what has outlived its retain_until (extending a date that is shorter than today's + // rules require), except under a hold. One budget of deletion attempts for the whole run (steps 4 and 5). + $deletions_left = ct_scan_retention_batch_limit(); $due = $wpdb->get_results( $wpdb->prepare( "SELECT job_id FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s)) AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY retain_until ASC LIMIT 50", $now, @@ -1150,35 +1256,46 @@ function ct_scan_retention_steps( $now ) { $recheck ) ); foreach ( (array) $due as $row ) { + if ( $deletions_left < 1 || microtime( true ) > $deadline ) { + break; // enough for one run: continue next time + } $outcome = ct_scan_delete_if_due( (int) $row->job_id, $now ); - if ( 'busy' === $outcome || microtime( true ) > $budget ) { - break; // the queue is in use (or the run is long enough): continue next time + if ( 'busy' === $outcome ) { + break; // the queue is in use: continue next time + } + if ( 'deleted' === $outcome || 'failed' === $outcome ) { + --$deletions_left; } - if ( 'deleted' !== $outcome ) { + if ( 'kept' === $outcome || 'failed' === $outcome ) { // Doubtful or failed: keep it, and look at it again tomorrow rather than on every run. $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); } } - // 5. A restored backup must not resurrect deleted records: delete matches again. - ct_scan_apply_tombstones( $now, $budget ); + // 5. A restored backup must not resurrect deleted records: delete matches again (same budget). + ct_scan_apply_tombstones( $now, $deadline, $deletions_left ); $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE deleted_at < %s', ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . max( 90, (int) ct_scan_config()['tombstone_retention_days'] ) . ' days' )->format( 'Y-m-d H:i:s' ) ) ); } /** * Delete again any job row (and linked request) that a deletion tombstone recognises after a restore. - * A job row matches on its numeric identifiers AND creation time AND scope; a request post matches only - * on the opaque identity recorded at creation, and only when no job row currently uses that request or - * job id (recycled auto-increment ids must never reach a live record). Holds are honoured. + * A job row matches on its numeric identifiers AND creation time AND scope; its request post is deleted with + * it only if the post carries the tombstone's opaque identity (a recycled post id is another record). A + * request post whose job row was not restored matches only on the opaque identity recorded at creation, and + * only when no job row currently uses that request or job id (recycled auto-increment ids must never reach a + * live record). Holds are honoured. Every attempt counts against $deletions_left (shared with step 4). */ -function ct_scan_apply_tombstones( $now, $budget = null ) { +function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = null ) { global $wpdb; - $table = ct_scan_jobs_table(); - $stones = ct_scan_tombstones_table(); - $budget = null === $budget ? microtime( true ) + 3.0 : $budget; + $table = ct_scan_jobs_table(); + $stones = ct_scan_tombstones_table(); + $deadline = null === $deadline ? microtime( true ) + 3.0 : $deadline; + if ( null === $deletions_left ) { + $deletions_left = ct_scan_retention_batch_limit(); + } // Candidates by numeric ids and creation time; the keyed scope MAC is compared in PHP, under the lock. - $ghosts = $wpdb->get_results( 'SELECT j.job_id, t.scope_mac FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.created_at = j.created_at ORDER BY j.job_id ASC LIMIT 50' ); + $ghosts = $wpdb->get_results( 'SELECT j.job_id, t.scope_mac, t.identity FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.created_at = j.created_at ORDER BY j.job_id ASC LIMIT 50' ); foreach ( (array) $ghosts as $ghost ) { - if ( microtime( true ) > $budget ) { + if ( $deletions_left < 1 || microtime( true ) > $deadline ) { return; } if ( ! ct_scan_lock_acquire() ) { @@ -1187,7 +1304,9 @@ function ct_scan_apply_tombstones( $now, $budget = null ) { try { $row = ct_scan_row( (int) $ghost->job_id ); if ( $row && hash_equals( (string) $ghost->scope_mac, ct_scan_tombstone_mac( $row->scope_hash ) ) && ! ct_scan_job_on_hold( $row, $now ) ) { - ct_scan_delete_job_records( $row, 'restored' ); + --$deletions_left; + // A failure leaves the row (and is retried next run); it never stops the other candidates. + ct_scan_delete_job_records( $row, 'restored', (string) $ghost->identity ); } } finally { ct_scan_lock_release(); @@ -1206,14 +1325,19 @@ function ct_scan_apply_tombstones( $now, $budget = null ) { . ' ORDER BY t.tombstone_id DESC LIMIT 50' ); foreach ( (array) $restored as $stone ) { - if ( microtime( true ) > $budget || ! ct_scan_lock_acquire() ) { + if ( $deletions_left < 1 || microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { return; } try { $live = $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$table} WHERE request_id = %d OR job_id = %d LIMIT 1", (int) $stone->request_id, (int) $stone->job_id ) ); clean_post_cache( (int) $stone->request_id ); if ( ! $live && hash_equals( (string) $stone->identity, ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_identity' ) ) && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { - wp_delete_post( (int) $stone->request_id, true ); + --$deletions_left; + if ( ! wp_delete_post( (int) $stone->request_id, true ) ) { + // Refused (a filter or the database): the post is kept and retried next run. This attempt + // has been counted, and the loop moves on, so one stuck post can neither spin nor block the rest. + clean_post_cache( (int) $stone->request_id ); + } } } finally { ct_scan_lock_release(); @@ -1262,10 +1386,15 @@ function ct_scan_admin_release_hold( $job_id ) { } try { $row = ct_scan_row( $job_id ); - if ( ! $row || empty( $row->hold_reason ) ) { + if ( ! $row || empty( $row->hold_reason ) || ! empty( $row->hold_released_at ) ) { return new WP_Error( 'hold_unavailable', 'This job has no hold to release.' ); } - $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_reason' => null, 'hold_until' => null, 'hold_released_by' => (int) $user->ID, 'hold_released_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%d', '%s' ), array( '%d' ) ); + // Releasing ends the hold now, it does not erase it: the purpose stays (with who released it and + // when), and the job keeps the same three-day grace as a hold that expires. A hold that already + // expired keeps its own (earlier) end, so a release never lengthens it. + $now = ct_scan_utc_now(); + $until = ( ct_scan_valid_datetime( $row->hold_until ) && $row->hold_until < $now ) ? $row->hold_until : $now; + $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_until' => $until, 'hold_released_by' => (int) $user->ID, 'hold_released_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%d', '%s' ), array( '%d' ) ); if ( false === $updated ) { return new WP_Error( 'hold_write_failed', 'The hold could not be released.' ); } @@ -1284,8 +1413,9 @@ function ct_scan_maintain() { } finally { ct_scan_lock_release(); } - // Retention runs after the queue lock is released (see ct_scan_retention_run()). - ct_scan_retention_run(); + // Retention runs after the queue lock is released (see ct_scan_retention_run()); a failure there never + // breaks the admin page. + ct_scan_retention_run_safely(); return true; } @@ -1854,12 +1984,12 @@ function ct_scan_is_list( $value ) { function ct_scan_claim( WP_REST_Request $request ) { $response = ct_scan_claim_locked( $request ); - // The worker polls this route every few seconds; retention piggybacks on it, outside the queue lock. + // The worker polls this route every few seconds; retention piggybacks on it, outside the queue lock, and + // only on a poll that hands out no job, so a lease is never delayed by a retention run. if ( $response instanceof WP_REST_Response && 200 === $response->get_status() ) { - try { - ct_scan_retention_run(); - } catch ( Throwable $error ) { - unset( $error ); // retention must never affect a claim + $data = $response->get_data(); + if ( is_array( $data ) && array_key_exists( 'job', $data ) && null === $data['job'] ) { + ct_scan_retention_run_safely(); // retention must never affect a claim } } return $response; @@ -2969,9 +3099,14 @@ function ct_scan_admin_page() { if ( 'awaiting_control' === $row->status && empty( $row->revoked_at ) ) { echo ct_scan_admin_form( (int) $row->job_id, 'authority_review', 'Record authority review', '

' ); } - if ( ct_scan_job_on_hold( $row, ct_scan_utc_now() ) ) { + $on_hold = ct_scan_job_on_hold( $row, ct_scan_utc_now() ); + if ( $on_hold && empty( $row->hold_released_at ) ) { echo '

Preservation hold until ' . esc_html( $row->hold_until ) . ' UTC: ' . esc_html( $row->hold_reason ) . '

' . ct_scan_admin_form( (int) $row->job_id, 'release_hold', 'Release hold', ' ' ); } else { + if ( $on_hold ) { + // Released, still inside the grace period: show it, and allow a new hold. + echo '

Preservation hold released ' . esc_html( $row->hold_released_at ) . ' UTC (records kept for a further ' . (int) ct_scan_hold_grace_days() . ' days): ' . esc_html( $row->hold_reason ) . '

'; + } echo ct_scan_admin_form( (int) $row->job_id, 'set_hold', 'Place preservation hold', '
days
' ); } if ( empty( $row->revoked_at ) && empty( $row->report_delivered_at ) ) { diff --git a/integrations/wordpress/tests/retention-maintain.php b/integrations/wordpress/tests/retention-maintain.php new file mode 100644 index 0000000..f56d177 --- /dev/null +++ b/integrations/wordpress/tests/retention-maintain.php @@ -0,0 +1,5 @@ +retain_until === ( new DateTimeImmutable( $nr->closed_at, new DateTimeZone( 'UTC' ) ) )->modify( '+6 years' )->format( 'Y-m-d H:i:s' ), 'A newer version is not kept six years after closure.' ); -// The date is fixed at closure: a later change of the stored acceptance date does not move it. +// The date is set at closure (and can only be extended when it comes due): a later change of the stored acceptance date does not move it. $fixed = $nr->retain_until; $wpdb->update( $jobs, array( 'terms_accepted_at' => ct_test_old( '-9 years' ) ), array( 'job_id' => $newer['job_id'] ) ); ct_test_maintain(); @@ -205,6 +205,8 @@ function ct_test_exists( $job ) { $wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $early['job_id'] ) ); ct_test_maintain(); ct_test_assert( ct_test_exists( $early ) && get_post( $early['request_id'] ), 'A retain_until earlier than three years after creation (or the derived date) deleted the evidence.' ); +$er = ct_scan_row( $early['job_id'] ); +ct_test_assert( $er->retain_until === ( new DateTimeImmutable( $er->terms_accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A stored date short of the derived one was not extended to the derived date.' ); // The derived-date check alone (the creation floor passes): accepted two years ago, created four years ago. $early2 = ct_test_aged_job( 'ret-early2.example.net', 'failed', '-2 years', '-1 day' ); $wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-4 years' ) ), array( 'job_id' => $early2['job_id'] ) ); @@ -214,9 +216,21 @@ function ct_test_exists( $job ) { ct_test_assert( ct_test_exists( $early2 ), 'A retain_until earlier than the date derived from the row deleted the evidence.' ); // The creation floor alone: a record created long after its acceptance date is not deleted on that date. $late_created = ct_test_aged_job( 'ret-late-created.example.net', 'failed', '-3 years -1 day', '-1 day' ); -$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-1 day' ) ), array( 'job_id' => $late_created['job_id'] ) ); +$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-2 days' ) ), array( 'job_id' => $late_created['job_id'] ) ); // still before its closure ct_test_maintain(); ct_test_assert( ct_test_exists( $late_created ), 'A record created after its stated acceptance date was deleted on that date.' ); +// ... but it is not kept forever either: it gets three full years from creation (later than the promised +// three years from acceptance), a definite date. +$lc = ct_scan_row( $late_created['job_id'] ); +ct_test_assert( $lc->retain_until === ( new DateTimeImmutable( $lc->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A legacy record created after its stated acceptance did not get a definite deadline three years after creation.' ); +// The same case once that later date has come: kept on the run that finds the shorter date, deleted when the later one arrives. +$late_due = ct_test_aged_job( 'ret-late-due.example.net', 'failed', '-3 years -5 days', '-1 day' ); +$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-3 years -1 hour' ) ), array( 'job_id' => $late_due['job_id'] ) ); +ct_test_maintain(); +$ld = ct_scan_row( $late_due['job_id'] ); +ct_test_assert( $ld && get_post( $late_due['request_id'] ) && $ld->retain_until === ( new DateTimeImmutable( $ld->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A legacy record created after its acceptance was deleted at the acceptance-based date, or its date was not extended.' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $late_due ) && ! get_post( $late_due['request_id'] ), 'A legacy record created after its acceptance was never deleted at its extended date.' ); $wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '+1 year' ) ), array( 'job_id' => $early['job_id'] ) ); ct_test_maintain(); ct_test_assert( ct_test_exists( $early ) && ct_scan_row( $early['job_id'] )->retain_until > ct_test_old( '+1 day' ), 'A later stored deletion date was shortened.' ); @@ -273,26 +287,41 @@ function ct_test_exists( $job ) { ct_scan_admin_set_hold( $rel['job_id'], 'Preserving records for dispute number 456.', 10 ); ct_test_assert( true === ct_scan_admin_release_hold( $rel['job_id'] ), 'Release failed.' ); $released = ct_scan_row( $rel['job_id'] ); -ct_test_assert( null === $released->hold_reason && null === $released->hold_until && 1 === (int) $released->hold_released_by && ! empty( $released->hold_released_at ), 'Release left the hold in place or did not record who released it.' ); +ct_test_assert( 'Preserving records for dispute number 456.' === $released->hold_reason && $released->hold_until <= ct_scan_utc_now() && $released->hold_until >= ct_test_old( '-1 minute' ) && 1 === (int) $released->hold_released_by && ! empty( $released->hold_released_at ), 'Release did not end the hold now (keeping its purpose) or did not record who released it.' ); ct_test_assert( 'hold_unavailable' === ct_scan_admin_release_hold( $rel['job_id'] )->get_error_code() && 'hold_unavailable' === ct_scan_admin_release_hold( 999999999 )->get_error_code() && 'hold_unavailable' === ct_scan_admin_release_hold( 0 )->get_error_code(), 'Releasing a hold that does not exist reported success.' ); $damaged = ct_test_aged_job( 'ret-damaged.example.net', 'failed', '-3 years -1 day', '-1 day' ); $wpdb->update( $jobs, array( 'hold_reason' => 'Purpose without an expiry (damaged).', 'hold_until' => null ), array( 'job_id' => $damaged['job_id'] ) ); ct_test_maintain(); -ct_test_assert( ! ct_test_exists( $rel ) && ct_test_exists( $damaged ), 'Release did not allow deletion, or a damaged hold allowed it.' ); +ct_test_assert( ct_test_exists( $rel ) && get_post( $rel['request_id'] ) && null !== ct_scan_row( $rel['job_id'] )->result_json && ct_test_exists( $damaged ), 'A released hold did not preserve the records for the grace period, or a damaged hold allowed deletion.' ); +// Once the grace period after the release has passed, the job is deleted as usual. +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-3 days -1 minute' ) ), array( 'job_id' => $rel['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $rel ) && ! get_post( $rel['request_id'] ) && ct_test_exists( $damaged ), 'A released hold kept the records after its grace period, or a damaged hold allowed deletion.' ); +// Releasing a hold that already expired keeps its own end (a release never lengthens a hold). +$lapsed = ct_test_aged_job( 'ret-lapsed.example.net', 'failed', '-1 day', '-1 hour' ); +ct_scan_admin_set_hold( $lapsed['job_id'], 'Preserving records for dispute number 789.', 10 ); +$lapsed_until = ct_test_old( '-2 days' ); +$wpdb->update( $jobs, array( 'hold_until' => $lapsed_until ), array( 'job_id' => $lapsed['job_id'] ) ); +ct_test_assert( true === ct_scan_admin_release_hold( $lapsed['job_id'] ) && $lapsed_until === ct_scan_row( $lapsed['job_id'] )->hold_until, 'Releasing an expired hold lengthened it.' ); wp_set_current_user( 0 ); ct_test_assert( 'hold_forbidden' === ct_scan_admin_release_hold( $damaged['job_id'] )->get_error_code() && ct_test_exists( $damaged ) && ! empty( ct_scan_row( $damaged['job_id'] )->hold_reason ), 'An anonymous caller released a hold.' ); // ---- Second review: no starvation, identity backfill, damaged holds, far-future dates --------------------------- -// Doubtful rows (a deletion date a year short of the floor) are judged once a day and cannot hide a due job. +// Rows with a stored date short of what today's rules require (here: a legacy acceptance a year before its +// record was created) are extended, not deleted, and rows with contradictory dates are judged once a day: +// neither can hide a due job. $doubtful = array(); for ( $i = 0; $i < 52; $i++ ) { $d = ct_test_aged_job( "ret-doubtful-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); - $wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-1 day' ) ), array( 'job_id' => $d['job_id'] ) ); + $wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-2 days' ) ), array( 'job_id' => $d['job_id'] ) ); // still before its closure $doubtful[] = $d; } +// A row whose dates contradict each other (closed before it was created) sorts first and is kept, judged at most daily. $due_behind = ct_test_aged_job( 'ret-behind-doubtful.example.net', 'failed', '-3 years -2 days', '-2 days' ); -$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 year' ) ), array( 'job_id' => $due_behind['job_id'] ) ); // sorts first, but is itself doubtful +$behind_accepted = ct_test_old( '-3 years -2 days' ); +$behind_closed = ( new DateTimeImmutable( $behind_accepted, new DateTimeZone( 'UTC' ) ) )->modify( '+10 seconds' )->format( 'Y-m-d H:i:s' ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => $behind_accepted, 'created_at' => ( new DateTimeImmutable( $behind_accepted, new DateTimeZone( 'UTC' ) ) )->modify( '+30 seconds' )->format( 'Y-m-d H:i:s' ), 'finished_at' => $behind_closed, 'closed_at' => $behind_closed, 'retain_until' => ct_test_old( '-1 year' ) ), array( 'job_id' => $due_behind['job_id'] ) ); // Due only after all the doubtful rows in date order (so a 50-row window would never reach it). $really_due = ct_test_aged_job( 'ret-really-due.example.net', 'failed', '-3 years -2 hours', '-1 hour' ); for ( $run = 0; $run < 3; $run++ ) { @@ -300,7 +329,12 @@ function ct_test_exists( $job ) { } ct_test_assert( ! ct_test_exists( $really_due ), 'Doubtful rows starved a due job.' ); ct_test_assert( 52 === count( array_filter( $doubtful, 'ct_test_exists' ) ) && ct_test_exists( $due_behind ), 'Doubtful rows were deleted.' ); -ct_test_assert( ! empty( ct_scan_row( $doubtful[0]['job_id'] )->retention_checked_at ), 'A doubtful row was not marked as examined.' ); +ct_test_assert( ! empty( ct_scan_row( $due_behind['job_id'] )->retention_checked_at ), 'A doubtful row was not marked as examined.' ); +// The rows whose stored date was short of three years after creation were given that later, definite date. +foreach ( $doubtful as $d ) { + $dr = ct_scan_row( $d['job_id'] ); + ct_test_assert( $dr->retain_until === ( new DateTimeImmutable( $dr->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A row with a stored date short of three years after creation was not given a definite later date.' ); +} foreach ( array_merge( $doubtful, array( $due_behind ) ) as $d ) { $wpdb->delete( $jobs, array( 'job_id' => $d['job_id'] ) ); wp_delete_post( $d['request_id'], true ); @@ -491,6 +525,136 @@ function ct_test_make_due( $job ) { ct_test_maintain(); ct_test_assert( ! ct_test_exists( $stuck ) && ! get_post( $stuck['request_id'] ), 'The deletion was not completed on the next run.' ); +// ---- Third review: extension instead of "never", clocks, shared budget, identities, leap days ---------------------- +// A longer period configured after closure: the earlier stored date is not honoured; retain_until is extended +// (never shortened) and the records are deleted when the later date arrives. +$run_raised = static function () { + $p = proc_open( array( PHP_BINARY, __DIR__ . '/retention-maintain.php' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pp, null, array_merge( getenv(), array( 'CT_TEST_WP_ROOT' => getenv( 'CT_TEST_WP_ROOT' ) ?: '/var/www/html', 'CT_TEST_EVIDENCE_YEARS' => '8', 'CT_TEST_LEGACY_YEARS' => '5' ) ) ); + $out = trim( stream_get_contents( $pp[1] ) ); + $err = trim( stream_get_contents( $pp[2] ) ); + proc_close( $p ); + ct_test_assert( 'maintained' === $out, "The raised-configuration maintenance run failed: $out $err" ); + wp_cache_flush(); // the other process changed the database behind this one's object cache +}; +$raise_past = ct_test_aged_job( 'ret-raise-past.example.net', 'cancelled', '-8 years -3 days', '-8 years -2 days', '2027-06-01-r3' ); +$raise_future = ct_test_aged_job( 'ret-raise-future.example.net', 'cancelled', '-6 years -3 days', '-6 years -2 days', '2027-06-01-r3' ); +$raise_legacy = ct_test_aged_job( 'ret-raise-legacy.example.net', 'failed', '-3 years -2 days', '-1 day' ); +foreach ( array( $raise_past, $raise_future, $raise_legacy ) as $j ) { + ct_test_make_due( $j ); // stamped at closure under six (legacy: three) years: due now +} +$plus = static function ( $date, $years ) { + return ( new DateTimeImmutable( $date, new DateTimeZone( 'UTC' ) ) )->modify( '+' . $years . ' years' )->format( 'Y-m-d H:i:s' ); +}; +$run_raised(); +$rp = ct_scan_row( $raise_past['job_id'] ); +$rf = ct_scan_row( $raise_future['job_id'] ); +$rl = ct_scan_row( $raise_legacy['job_id'] ); +ct_test_assert( $rp && $rf && $rl && get_post( $raise_past['request_id'] ) && get_post( $raise_future['request_id'] ) && get_post( $raise_legacy['request_id'] ), 'Evidence was deleted at the date fixed under a shorter configuration.' ); +ct_test_assert( $rp->retain_until === $plus( $rp->closed_at, 8 ) && $rf->retain_until === $plus( $rf->closed_at, 8 ) && $rl->retain_until === $plus( $rl->terms_accepted_at, 5 ), 'retain_until was not extended to the longer configured period.' ); +$run_raised(); +ct_test_assert( ! ct_test_exists( $raise_past ) && ! get_post( $raise_past['request_id'] ), 'Evidence was not deleted once the extended date arrived.' ); +ct_test_assert( ct_test_exists( $raise_future ) && ct_test_exists( $raise_legacy ), 'Evidence was deleted before its extended date.' ); +// Back under the shorter configuration, the extended dates are not shortened again. +ct_test_maintain(); +ct_test_assert( ct_test_exists( $raise_future ) && ct_test_exists( $raise_legacy ) && $rf->retain_until === ct_scan_row( $raise_future['job_id'] )->retain_until && $rl->retain_until === ct_scan_row( $raise_legacy['job_id'] )->retain_until, 'An extended deletion date was shortened when the configuration went back.' ); + +// Leap days: a period that starts on 29 February ends on 1 March, never on 28 February. +$leap_r2 = (object) array( 'status' => 'failed', 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-29 12:00:00', 'created_at' => '2024-02-29 12:00:30', 'closed_at' => '2024-03-02 00:00:00', 'retain_until' => '2027-03-01 12:00:00' ); +$leap_new = (object) array( 'status' => 'failed', 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2027-06-01-r3', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2030-03-01 12:00:00' ); +ct_test_assert( '2027-03-01 12:00:00' === ct_scan_retain_until( $leap_r2 ) && '2027-03-01 12:00:00' === ct_scan_required_retain_until( $leap_r2 ) && '2030-03-01 12:00:00' === ct_scan_retain_until( $leap_new ), 'A period starting on 29 February does not end on 1 March.' ); +ct_test_assert( ! ct_scan_deletion_allowed( $leap_r2, '2027-02-28 23:59:59' ) && ct_scan_deletion_allowed( $leap_r2, '2027-03-01 12:00:00' ) && ! ct_scan_deletion_allowed( $leap_new, '2030-02-28 23:59:59' ) && ct_scan_deletion_allowed( $leap_new, '2030-03-01 12:00:00' ), 'A leap-day period allowed deletion early (or never).' ); +$leap_r2->retain_until = '2027-02-28 12:00:00'; +ct_test_assert( array( 'extend', '2027-03-01 12:00:00' ) === ct_scan_deletion_decision( $leap_r2, '2027-03-05 00:00:00' ), 'A stored 28 February date for a 29 February acceptance was not extended to 1 March.' ); + +// The PHP and database clocks disagreeing by more than five minutes (either way) stops deletions; logged once. +$skewed = ct_test_aged_job( 'ret-skew.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $skewed ); +$log = tempnam( sys_get_temp_dir(), 'ctret' ); +$previous_log = ini_set( 'error_log', $log ); +$wpdb->query( 'SET @@session.timestamp = ' . ( time() + 600 ) ); +ct_test_assert( ! ct_scan_clock_is_sane( ct_scan_utc_now() ), 'A database clock ten minutes ahead of PHP was accepted.' ); +ct_test_maintain(); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $skewed ) && get_post( $skewed['request_id'] ), 'Evidence was deleted while the PHP and database clocks disagreed.' ); +$wpdb->query( 'SET @@session.timestamp = ' . ( time() - 600 ) ); +ct_test_assert( ! ct_scan_clock_is_sane( ct_scan_utc_now() ), 'A database clock ten minutes behind PHP was accepted.' ); +$wpdb->query( 'SET @@session.timestamp = DEFAULT' ); +ct_test_assert( ct_scan_clock_is_sane( ct_scan_utc_now() ), 'Agreeing clocks were refused.' ); +$clock_logged = substr_count( (string) file_get_contents( $log ), 'retention deletions skipped' ); +ct_test_maintain(); +ini_set( 'error_log', (string) $previous_log ); +@unlink( $log ); +ct_test_assert( 1 === $clock_logged, "The clock problem was logged $clock_logged times over two runs instead of once." ); +ct_test_assert( ! ct_test_exists( $skewed ) && false === get_option( 'ct_scan_retention_clock_warned' ), 'Deletion did not resume, or the warning was not reset, once the clocks agreed.' ); + +// A due job whose linked post carries another record's identity is kept whole (no tombstone, no scrub). +$foreign = ct_test_aged_job( 'ret-foreign.example.net', 'failed', '-3 years -1 day', '-1 day' ); +update_post_meta( $foreign['request_id'], '_ct_scan_identity', str_repeat( 'b', 32 ) ); +ct_test_maintain(); +$fr = ct_scan_row( $foreign['job_id'] ); +ct_test_assert( $fr && get_post( $foreign['request_id'] ) && 'fixture@example.com' === $fr->contact_email && null === $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $foreign['job_id'] ) ), 'A job was (partly) deleted although its linked post belongs to another record.' ); +// A restored job row whose request id now belongs to another post (a recycled id): the row is deleted again, the post is kept. +$recycled = ct_test_aged_job( 'ret-recycled.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $recycled ); +$saved = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE job_id = %d", $recycled['job_id'] ), ARRAY_A ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $recycled ) && ! get_post( $recycled['request_id'] ), 'Fixture expected the recycled-id job to be deleted.' ); +$saved['retain_until'] = ct_test_old( '+1 year' ); // only the tombstone can recognise it +$wpdb->insert( $jobs, $saved ); +wp_insert_post( array( 'import_id' => $recycled['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'another record' ) ); +update_post_meta( $recycled['request_id'], '_ct_scan_identity', str_repeat( 'e', 32 ) ); +ct_test_assert( ct_test_exists( $recycled ) && get_post( $recycled['request_id'] ), 'Fixture failed to restore the row and recycle the post id.' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $recycled ) && get_post( $recycled['request_id'] ), 'A restored row survived, or its tombstone deleted a post that belongs to another record.' ); +wp_delete_post( $recycled['request_id'], true ); + +// One budget of 50 deletion attempts per run, shared by the evidence step and both tombstone loops. +ct_test_maintain(); +$ghost_jobs = array(); +$ghost_rows = array(); +for ( $i = 0; $i < 10; $i++ ) { + $g = ct_test_aged_job( "ret-budget-ghost-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_make_due( $g ); + $ghost_rows[] = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE job_id = %d", $g['job_id'] ), ARRAY_A ); + $ghost_jobs[] = $g; +} +ct_test_maintain(); +ct_test_assert( 0 === count( array_filter( $ghost_jobs, 'ct_test_exists' ) ), 'Fixture expected the budget ghosts to be deleted.' ); +foreach ( $ghost_rows as $g ) { + $g['retain_until'] = ct_test_old( '+1 year' ); // only the tombstone loop can reach these + $wpdb->insert( $jobs, $g ); +} +$budget_jobs = array(); +for ( $i = 0; $i < 45; $i++ ) { + $budget_jobs[] = ct_test_aged_job( "ret-budget-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_make_due( end( $budget_jobs ) ); +} +ct_scan_retention_run( true ); +$left = count( array_filter( $ghost_jobs, 'ct_test_exists' ) ) + count( array_filter( $budget_jobs, 'ct_test_exists' ) ); +ct_test_assert( $left >= 5 && $left < 55, "One run did not keep to a single budget of 50 deletions ($left of 55 remain)." ); +for ( $run = 0; $run < 4 && $left > 0; $run++ ) { + ct_test_maintain(); + $left = count( array_filter( $ghost_jobs, 'ct_test_exists' ) ) + count( array_filter( $budget_jobs, 'ct_test_exists' ) ); +} +ct_test_assert( 0 === $left, "Later runs did not finish the remaining deletions ($left remain)." ); + +// A worker poll that is handed a job does not wait for retention; a poll with nothing to hand out runs it. +$wpdb->update( $jobs, array( 'status' => 'cancelled', 'finished_at' => ct_scan_utc_now() ), array( 'job_id' => $live['job_id'] ) ); // the only other queued job here +$claim_due = ct_test_aged_job( 'ret-claim-due.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $claim_due ); +list( $claim_request, $claim_fields ) = ct_test_request( 'ret-claim.example.net' ); +$request_ids[] = $claim_request; +$claim_event = ct_scan_on_request_ready( $claim_request, $claim_fields ); +ct_test_queue_job( $claim_event['job_id'] ); +delete_transient( 'ct_scan_retention_last' ); +$claimed = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data(); +ct_test_assert( is_array( $claimed['job'] ) && ct_test_exists( $claim_due ), 'A claim that handed out a job ran retention first (or no job was handed out).' ); +delete_transient( 'ct_scan_retention_last' ); +$idle = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data(); +ct_test_assert( null === $idle['job'] && ! ct_test_exists( $claim_due ), 'A claim with nothing to hand out did not run retention.' ); +ct_test_end_job( (int) $claimed['job']['id'] ); +delete_transient( 'ct_scan_retention_last' ); + foreach ( $request_ids as $id ) { $wpdb->delete( $jobs, array( 'request_id' => $id ), array( '%d' ) ); wp_delete_post( $id, true ); diff --git a/integrations/wordpress/tests/support.php b/integrations/wordpress/tests/support.php index 96ce913..6d0efd6 100644 --- a/integrations/wordpress/tests/support.php +++ b/integrations/wordpress/tests/support.php @@ -21,6 +21,8 @@ 'target_daily_limit' => 100, // CT_TEST_EVIDENCE_YEARS: a raw value (words, NULL, TRUE, ARRAY, numbers) for evidence_retention_years. 'evidence_retention_years' => false === getenv( 'CT_TEST_EVIDENCE_YEARS' ) ? 6 : ( array( 'NULL' => null, 'TRUE' => true, 'FALSE' => false, 'ARRAY' => array( 9 ) )[ getenv( 'CT_TEST_EVIDENCE_YEARS' ) ] ?? getenv( 'CT_TEST_EVIDENCE_YEARS' ) ), + // CT_TEST_LEGACY_YEARS: legacy_retention_years for r2-and-earlier acceptances (default three). + 'legacy_retention_years' => false === getenv( 'CT_TEST_LEGACY_YEARS' ) ? 3 : getenv( 'CT_TEST_LEGACY_YEARS' ), // CT_TEST_POLICY: legacy (default; the grandfathered r2 flow), strict, or none (no decision declared). 'terms_policy' => 'none' === getenv( 'CT_TEST_POLICY' ) ? array() : array( ( getenv( 'CT_TEST_VERSION' ) ?: '2026-10-21-r2' ) => 'strict' === getenv( 'CT_TEST_POLICY' ) ? 'strict' : 'legacy' ), 'send_accepted_copy' => '1' === getenv( 'CT_TEST_COPY' ), From e7ca212c21f927b53a8a38806d25140129bc0a8a Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Fri, 23 Oct 2026 15:48:29 -0400 Subject: [PATCH 5/9] Retention tests: raised-config case uses dates after 2020; helper reports a skipped run The non-legacy case whose extended date has already come needs a closure more than eight years ago, which the 2020 trust floor (correctly) refuses; use a legacy acceptance raised from three to five years instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- integrations/wordpress/tests/retention-maintain.php | 4 ++-- integrations/wordpress/tests/retention.php | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/integrations/wordpress/tests/retention-maintain.php b/integrations/wordpress/tests/retention-maintain.php index f56d177..5f6ac7d 100644 --- a/integrations/wordpress/tests/retention-maintain.php +++ b/integrations/wordpress/tests/retention-maintain.php @@ -1,5 +1,5 @@ retain_until === $plus( $rp->closed_at, 8 ) && $rf->retain_until === $plus( $rf->closed_at, 8 ) && $rl->retain_until === $plus( $rl->terms_accepted_at, 5 ), 'retain_until was not extended to the longer configured period.' ); +ct_test_assert( $rp->retain_until === $plus( $rp->terms_accepted_at, 5 ) && $rf->retain_until === $plus( $rf->closed_at, 8 ) && $rl->retain_until === $plus( $rl->terms_accepted_at, 5 ), "retain_until was not extended to the longer configured period ({$rp->retain_until} {$rf->retain_until} {$rl->retain_until}; checked {$rp->retention_checked_at})." ); $run_raised(); ct_test_assert( ! ct_test_exists( $raise_past ) && ! get_post( $raise_past['request_id'] ), 'Evidence was not deleted once the extended date arrived.' ); ct_test_assert( ct_test_exists( $raise_future ) && ct_test_exists( $raise_legacy ), 'Evidence was deleted before its extended date.' ); From f2a33e665a6cb24d0ea6fd3fe9e0149c77276a6e Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Fri, 23 Oct 2026 16:15:39 -0400 Subject: [PATCH 6/9] Retention: a second hold never shortens a standing one; identity and tombstone fixes - ct_scan_admin_set_hold refuses ('hold_active') while an unreleased hold still protects the job (incl. grace), and never lets a new hold end earlier than a released one; the write is a compare-and-set on the hold values read under the queue lock ('hold_changed' otherwise). The admin notice explains it. - A job without an opaque identity is kept (decision and due query) until the backfill gives it one; the backfill also covers empty identities. - Retention deletion requires the linked post to carry exactly the job's non-empty identity; an empty post identity keeps everything. - Tombstone candidates exclude held, leased and recently failed rows in SQL and are paged by key, so skipped rows cannot starve later restored rows; a failed restored-row deletion is retried after a day. - Docs: hold replacement rule, identity requirement, salt rotation and the far-future timestamp halt. - Tests for each. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/WORDPRESS.md | 30 ++++-- integrations/wordpress/ct-scan.php | 118 ++++++++++++++++----- integrations/wordpress/tests/retention.php | 88 ++++++++++++++- 3 files changed, 200 insertions(+), 36 deletions(-) diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 18e983f..2732f7f 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -571,7 +571,11 @@ queue page): makes at most 50 deletion attempts in total, shared by the evidence step and the tombstone clean-up. A job judged doubtful is looked at again at most daily (`retention_checked_at`), so doubtful rows cannot hide due ones, and the queue - lock is held briefly. + lock is held briefly. A job that has no opaque identity yet (created before it + existed and not yet reached by the backfill, which handles 100 jobs per run) is + kept until it has one. A single implausible far-future timestamp on any job + (later than now, before 2200) halts every deletion until it is corrected; this + is logged once, not on every run. - **Narrow preservation hold** (admin queue): one job, a stated purpose of 20 to 190 characters, a mandatory expiry of at most 730 days, recorded with who set it and when (and who released it). While it stands nothing about that job is @@ -579,7 +583,13 @@ queue page): the job stays preserved for a further three days. Releasing ends the hold at that moment (an already expired hold keeps its own end). The stated purpose is kept, and who released it and when are recorded. A damaged hold (purpose - without an expiry) preserves rather than deletes. + without an expiry) preserves rather than deletes. A hold that still protects + the job (unexpired or in its grace period) and has not been released is never + replaced: a second "place hold" (a stale tab, another administrator, a direct + POST) is refused and changes nothing, so it can neither shorten the hold nor + erase its purpose and setter. To change a hold, release it first (the job stays + protected through the grace period), then place the new one; a new hold never + ends earlier than the released one. - **Retention runs outside the queue lock.** Only one retention run at a time (its own advisory lock, throttled to about once a minute on worker polls that hand out no job, so a lease is never delayed by it, and run again on the admin @@ -600,13 +610,21 @@ queue page): reach a live record). A restored job row that holds a lease (a leased or running scan, or a control check in progress) is deleted again only after that bounded lease ends, so the one-at-a-time gate is never freed while its - traffic may be in flight. Jobs created before the identity existed are given one + traffic may be in flight. Restored rows that cannot be acted on (held, leased, + or whose deletion failed in the last day) are skipped in the query and the + candidates are paged, so they cannot hide later restored rows. Jobs created before the identity existed are given one (adopting what their request already carries) by the next maintenance run. Tombstones are kept 120 days, longer than the 90-day backup - rotation, then purged. + rotation, then purged. The scope MAC is keyed with `wp_salt( 'secure_auth' )`: + rotating that salt (or `SECURE_AUTH_KEY`/`SECURE_AUTH_SALT`) stops existing + tombstones from recognising restored job rows (restored request posts are still + matched by their opaque identity). Normal retention still deletes restored rows + when their own deletion date is due. - Deletion scrubs the contact fields first, then deletes the private request and the job row; if any step fails the record stays (and is retried) rather than - being half-deleted. A due job whose linked request carries another record's - identity is not touched at all. + being half-deleted. When its linked request exists, a due job is deleted only + if that request carries exactly the job's opaque identity; if the request has + another identity, or none, neither the request nor the job is touched (it is + re-examined daily and needs an administrator to repair the identity). Not covered here: backup rotation itself, the contract archive's historical Git objects and clones (issue 11), unsigned caller leads (there is no lead record in diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index a6d4a8d..485784f 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -1002,6 +1002,11 @@ function ct_scan_deletion_decision( $row, $now ) { if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) || ct_scan_job_on_hold( $row, $now ) ) { return array( 'keep', null ); } + // A job without its opaque identity (not yet reached by the identity backfill) is kept until it has one: + // deleting it would leave a tombstone that cannot recognise its restored request. + if ( ! isset( $row->identity ) || ! is_string( $row->identity ) || '' === $row->identity ) { + return array( 'keep', null ); + } foreach ( array( $row->created_at, $row->closed_at, $row->retain_until ) as $date ) { if ( ! ct_scan_valid_datetime( $date ) ) { return array( 'keep', null ); @@ -1055,8 +1060,8 @@ function ct_scan_record_tombstone( $row, $reason ) { * and a tombstone exists. * * The request post is deleted only when it provably belongs to this job (post ids can be recycled after a - * restore). For a retention deletion, a post that carries a different opaque identity than the job keeps - * everything (nothing is deleted; retried later). For a restored job row recognised by its tombstone + * restore). For a retention deletion, a post that does not carry exactly the job's (non-empty) opaque + * identity keeps everything (nothing is deleted; retried later). For a restored job row recognised by its tombstone * ($tombstone_identity given), the post is deleted only if it carries exactly that identity; otherwise the * post is another record and is left alone while the restored row itself is deleted again. */ @@ -1070,7 +1075,9 @@ function ct_scan_delete_job_records( $row, $reason, $tombstone_identity = null ) $post_identity = ct_scan_meta_string( (int) $post->ID, '_ct_scan_identity' ); if ( null !== $tombstone_identity ) { $ours = is_string( $tombstone_identity ) && '' !== $tombstone_identity && hash_equals( $tombstone_identity, $post_identity ); - } elseif ( '' !== $post_identity && is_string( $row->identity ) && '' !== $row->identity && ! hash_equals( $row->identity, $post_identity ) ) { + } elseif ( ! is_string( $row->identity ) || '' === $row->identity || ! hash_equals( $row->identity, $post_identity ) ) { + // Only an exact, non-empty identity match proves the post is this job's (an empty or different + // identity may be a recycled id): keep everything and retry later. return false; } } @@ -1233,12 +1240,12 @@ function ct_scan_retention_steps( $now ) { $recheck = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-1 day' )->format( 'Y-m-d H:i:s' ); // 1b. Jobs that predate the opaque identity get one (and so does their request) so a restored request // can be recognised after deletion. - $anonymous = $wpdb->get_results( "SELECT job_id,request_id FROM {$table} WHERE identity IS NULL ORDER BY job_id ASC LIMIT 100" ); + $anonymous = $wpdb->get_results( "SELECT job_id,request_id,identity FROM {$table} WHERE identity IS NULL OR identity = '' ORDER BY job_id ASC LIMIT 100" ); foreach ( (array) $anonymous as $row ) { // Adopt an identity the request already carries; otherwise mint one for both. $carried = ct_scan_meta_string( (int) $row->request_id, '_ct_scan_identity' ); $identity = 1 === preg_match( '/\A[a-f0-9]{32}\z/', $carried ) ? $carried : bin2hex( random_bytes( 16 ) ); - if ( 1 === $wpdb->update( $table, array( 'identity' => $identity ), array( 'job_id' => (int) $row->job_id, 'identity' => null ), array( '%s' ), array( '%d', '%s' ) ) && $identity !== $carried ) { + if ( 1 === $wpdb->update( $table, array( 'identity' => $identity ), array( 'job_id' => (int) $row->job_id, 'identity' => $row->identity ), array( '%s' ), array( '%d', '%s' ) ) && $identity !== $carried ) { $post = get_post( (int) $row->request_id ); if ( $post && 'ct_promo_request' === $post->post_type ) { update_post_meta( (int) $row->request_id, '_ct_scan_identity', $identity ); @@ -1274,7 +1281,7 @@ function ct_scan_retention_steps( $now ) { // rules require), except under a hold. One budget of deletion attempts for the whole run (steps 4 and 5). $deletions_left = ct_scan_retention_batch_limit(); $due = $wpdb->get_results( $wpdb->prepare( - "SELECT job_id FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s)) AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY retain_until ASC LIMIT 50", + "SELECT job_id FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND identity IS NOT NULL AND identity <> '' AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s)) AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY retain_until ASC LIMIT 50", $now, $grace_edge, $recheck @@ -1317,26 +1324,55 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu $deletions_left = ct_scan_retention_batch_limit(); } // Candidates by numeric ids and creation time; the keyed scope MAC is compared in PHP, under the lock. - $ghosts = $wpdb->get_results( 'SELECT j.job_id, t.scope_mac, t.identity FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.created_at = j.created_at ORDER BY j.job_id ASC LIMIT 50' ); - foreach ( (array) $ghosts as $ghost ) { - if ( $deletions_left < 1 || microtime( true ) > $deadline ) { - return; - } - if ( ! ct_scan_lock_acquire() ) { - return; - } - try { - $row = ct_scan_row( (int) $ghost->job_id ); - // A restored row that holds a live lease (a scan, or a control check in progress) is left until the - // lease ends (bounded; maintenance reclaims it): deleting it would free the one-at-a-time gate while - // target traffic for it may still be in flight. - if ( $row && hash_equals( (string) $ghost->scope_mac, ct_scan_tombstone_mac( $row->scope_hash ) ) && ! ct_scan_job_on_hold( $row, $now ) && ! ct_scan_job_holds_lease( $row ) ) { - --$deletions_left; - // A failure leaves the row (and is retried next run); it never stops the other candidates. - ct_scan_delete_job_records( $row, 'restored', (string) $ghost->identity ); + // Rows that cannot be acted on now are excluded in SQL (held or in grace, holding a lease, or whose + // deletion failed within the last day), and the candidates are paged by key, so rows that are skipped + // can never starve later restored rows. + $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); + $recheck = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-1 day' )->format( 'Y-m-d H:i:s' ); + $after_job = 0; + $after_stone = 0; + for ( $page = 0; $page < 20; $page++ ) { + $ghosts = $wpdb->get_results( $wpdb->prepare( + 'SELECT j.job_id, t.tombstone_id, t.scope_mac, t.identity FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.created_at = j.created_at' + . ' WHERE (j.job_id > %d OR (j.job_id = %d AND t.tombstone_id > %d))' + . " AND (j.hold_reason IS NULL OR (j.hold_until >= '2020-01-01 00:00:00' AND j.hold_until <= %s))" + . " AND j.status NOT IN ('leased','running') AND NOT (j.status = 'awaiting_control' AND COALESCE(j.control_state, '') = 'checking')" + . ' AND (j.retention_checked_at IS NULL OR j.retention_checked_at < %s)' + . ' ORDER BY j.job_id ASC, t.tombstone_id ASC LIMIT 50', + $after_job, + $after_job, + $after_stone, + $grace_edge, + $recheck + ) ); + if ( empty( $ghosts ) ) { + break; + } + foreach ( (array) $ghosts as $ghost ) { + $after_job = (int) $ghost->job_id; + $after_stone = (int) $ghost->tombstone_id; + if ( $deletions_left < 1 || microtime( true ) > $deadline ) { + return; + } + if ( ! ct_scan_lock_acquire() ) { + return; + } + try { + $row = ct_scan_row( (int) $ghost->job_id ); + // A restored row that holds a live lease (a scan, or a control check in progress) is left until the + // lease ends (bounded; maintenance reclaims it): deleting it would free the one-at-a-time gate while + // target traffic for it may still be in flight. Re-checked here under the lock. + if ( $row && hash_equals( (string) $ghost->scope_mac, ct_scan_tombstone_mac( $row->scope_hash ) ) && ! ct_scan_job_on_hold( $row, $now ) && ! ct_scan_job_holds_lease( $row ) ) { + --$deletions_left; + // A failure leaves the row (retried after a day, so it cannot take the budget of every run); + // it never stops the other candidates. + if ( ! ct_scan_delete_job_records( $row, 'restored', (string) $ghost->identity ) ) { + $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); + } + } + } finally { + ct_scan_lock_release(); } - } finally { - ct_scan_lock_release(); } } // A restored request post whose job row was not restored: matched in SQL on the opaque identity @@ -1372,6 +1408,14 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu } } +/** + * Place a preservation hold. A hold that still protects the job (unexpired, or inside its grace period) and + * has not been released is never replaced: a second "place hold" (a stale tab, two administrators, a direct + * POST) is refused with 'hold_active', so it can neither move the expiry earlier nor erase the stated purpose + * and who set it. To change a standing hold, release it first (the job stays protected through the grace + * period), then place the new one. A released hold in its grace period may be replaced, but never by a hold + * that ends earlier. The write is a compare-and-set on the hold values read under the queue lock. + */ function ct_scan_admin_set_hold( $job_id, $reason, $days ) { global $wpdb; $user = wp_get_current_user(); @@ -1391,11 +1435,24 @@ function ct_scan_admin_set_hold( $job_id, $reason, $days ) { if ( ! $row ) { return new WP_Error( 'hold_unavailable', 'This job no longer exists.' ); } - $until = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '+' . $days . ' days' )->format( 'Y-m-d H:i:s' ); - $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_reason' => $reason, 'hold_until' => $until, 'hold_set_by' => (int) $user->ID, 'hold_set_at' => ct_scan_utc_now(), 'hold_released_by' => null, 'hold_released_at' => null ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%d', '%s', '%d', '%s' ), array( '%d' ) ); + $now = ct_scan_utc_now(); + $until = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '+' . $days . ' days' )->format( 'Y-m-d H:i:s' ); + if ( ct_scan_job_on_hold( $row, $now ) && ( empty( $row->hold_released_at ) || ! ct_scan_valid_datetime( $row->hold_until ) || $row->hold_until > $until ) ) { + return new WP_Error( 'hold_active', 'This job already has a preservation hold; it was not changed. Release it first to place a different one.' ); + } + $updated = $wpdb->update( + ct_scan_jobs_table(), + array( 'hold_reason' => $reason, 'hold_until' => $until, 'hold_set_by' => (int) $user->ID, 'hold_set_at' => $now, 'hold_released_by' => null, 'hold_released_at' => null ), + array( 'job_id' => (int) $row->job_id, 'hold_reason' => $row->hold_reason, 'hold_until' => $row->hold_until, 'hold_released_at' => $row->hold_released_at ), + array( '%s', '%s', '%d', '%s', '%d', '%s' ), + array( '%d', '%s', '%s', '%s' ) + ); if ( false === $updated ) { return new WP_Error( 'hold_write_failed', 'The hold could not be recorded.' ); } + if ( 1 !== $updated ) { + return new WP_Error( 'hold_changed', 'The hold changed while this request was handled; reload the queue and try again.' ); + } } finally { ct_scan_lock_release(); } @@ -3109,7 +3166,12 @@ function ct_scan_admin_page() { echo '

PHP2Shell screening queue

'; echo '

Worker API: ' . ( ct_scan_enabled() ? 'enabled' : 'disabled' ) . '. This page never displays the worker secret. The screening covers public passive evidence only; broader checks remain manual.

'; if ( isset( $_GET['ct_scan_notice'] ) && is_string( $_GET['ct_scan_notice'] ) ) { - echo '

' . esc_html( sanitize_key( wp_unslash( $_GET['ct_scan_notice'] ) ) ) . '

'; + $notice = sanitize_key( wp_unslash( $_GET['ct_scan_notice'] ) ); + $messages = array( + 'hold_active' => 'Not changed: this job already has a preservation hold. A second hold cannot replace or shorten it; release the existing hold first (the records stay protected through the grace period), then place the new one.', + 'hold_changed' => 'Not changed: the hold was modified at the same time. Reload the queue and try again.', + ); + echo '

' . esc_html( $messages[ $notice ] ?? $notice ) . '

'; } if ( empty( $rows ) ) { echo '

No screening jobs are stored.

'; diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 7d56375..629ebb1 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -407,6 +407,90 @@ function ct_test_exists( $job ) { $rh = ct_scan_row( $reheld['job_id'] ); ct_test_assert( null === $rh->hold_released_by && null === $rh->hold_released_at && 'Second preservation purpose for this job.' === $rh->hold_reason, 'A new hold kept the audit of an earlier release.' ); +// ---- Fourth review: a second hold never shortens or replaces a standing one; identities; tombstone paging --------- +wp_set_current_user( 1 ); +$long_hold = ct_test_aged_job( 'ret-long-hold.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $long_hold ); +ct_test_assert( true === ct_scan_admin_set_hold( $long_hold['job_id'], 'Litigation hold for dispute number 2468.', 730 ), 'A 730-day hold was refused.' ); +$lh = ct_scan_row( $long_hold['job_id'] ); +$second_admin = wp_insert_user( array( 'user_login' => 'ct_ret_admin_' . wp_generate_password( 6, false ), 'user_pass' => wp_generate_password(), 'role' => 'administrator' ) ); +wp_set_current_user( (int) $second_admin ); +foreach ( array( 1, 729, 730 ) as $days ) { + $second = ct_scan_admin_set_hold( $long_hold['job_id'], 'A different, later purpose for this job.', $days ); + $now_lh = ct_scan_row( $long_hold['job_id'] ); + ct_test_assert( is_wp_error( $second ) && 'hold_active' === $second->get_error_code() && $lh->hold_until === $now_lh->hold_until && $lh->hold_reason === $now_lh->hold_reason && (int) $lh->hold_set_by === (int) $now_lh->hold_set_by && $lh->hold_set_at === $now_lh->hold_set_at, "A second hold ($days days) replaced or shortened a standing hold, or lost its purpose or setter." ); +} +ct_test_maintain(); +ct_test_assert( ct_test_exists( $long_hold ) && get_post( $long_hold['request_id'] ), 'A due job lost its records after a refused second hold.' ); +// Inside the grace period of an expired (unreleased) hold, a new hold is refused too: the original purpose and setter stay. +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $long_hold['job_id'] ) ); +$lh = ct_scan_row( $long_hold['job_id'] ); +$grace_try = ct_scan_admin_set_hold( $long_hold['job_id'], 'A different, later purpose for this job.', 30 ); +ct_test_assert( is_wp_error( $grace_try ) && 'hold_active' === $grace_try->get_error_code() && $lh->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until && $lh->hold_reason === ct_scan_row( $long_hold['job_id'] )->hold_reason, 'A hold in its grace period was replaced without being released.' ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $long_hold ), 'A job in its hold grace period was deleted.' ); +// Released (still in grace): a new hold may be placed, and it never ends earlier than the released one. +ct_test_assert( true === ct_scan_admin_release_hold( $long_hold['job_id'] ) && $lh->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until, 'Releasing an expired hold changed its end.' ); +ct_test_assert( true === ct_scan_admin_set_hold( $long_hold['job_id'], 'A different, later purpose for this job.', 30 ), 'A new hold after a release was refused.' ); +$lh2 = ct_scan_row( $long_hold['job_id'] ); +ct_test_assert( 'A different, later purpose for this job.' === $lh2->hold_reason && (int) $second_admin === (int) $lh2->hold_set_by && $lh2->hold_until > ct_test_old( '+29 days' ) && null === $lh2->hold_released_at, 'A new hold after a release was not recorded with its own purpose and setter.' ); +// A damaged state (released, yet an expiry still in the future) is never shortened. +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '+300 days' ), 'hold_released_at' => ct_test_old( '-1 hour' ), 'hold_released_by' => 1 ), array( 'job_id' => $long_hold['job_id'] ) ); +$lh3 = ct_scan_row( $long_hold['job_id'] ); +$short = ct_scan_admin_set_hold( $long_hold['job_id'], 'A shorter purpose that must not win.', 5 ); +ct_test_assert( is_wp_error( $short ) && $lh3->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until && $lh3->hold_reason === ct_scan_row( $long_hold['job_id'] )->hold_reason, 'A hold whose expiry is still in the future was shortened.' ); +wp_set_current_user( 1 ); +$wpdb->delete( $jobs, array( 'job_id' => $long_hold['job_id'] ) ); + +// A due job without its opaque identity is kept until the backfill gives it one (never a tombstone without identity). +$no_identity = (object) array( 'status' => 'failed', 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2027-02-20 12:00:00', 'identity' => null ); +ct_test_assert( array( 'keep', null ) === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' ), 'A job without an identity was judged deletable.' ); +$no_identity->identity = ''; +ct_test_assert( array( 'keep', null ) === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' ), 'A job with an empty identity was judged deletable.' ); +unset( $no_identity->identity ); +ct_test_assert( array( 'keep', null ) === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' ), 'A row read without its identity was judged deletable.' ); +$no_identity->identity = str_repeat( 'a', 32 ); +ct_test_assert( 'delete' === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' )[0], 'Fixture expected the same job with an identity to be deletable.' ); +$empty_identity = ct_test_aged_job( 'ret-empty-identity.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $empty_identity ); +$wpdb->update( $jobs, array( 'identity' => '' ), array( 'job_id' => $empty_identity['job_id'] ) ); +delete_post_meta( $empty_identity['request_id'], '_ct_scan_identity' ); +ct_test_maintain(); +$ei_stone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $empty_identity['job_id'] ) ); +ct_test_assert( ! $ei_stone || 1 === preg_match( '/\A[a-f0-9]{32}\z/', (string) $ei_stone->identity ), 'A job was deleted with an empty identity in its tombstone.' ); +ct_test_assert( null === $wpdb->get_var( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . " WHERE reason = 'retention' AND (identity IS NULL OR identity = '')" ), 'A retention tombstone without an identity exists.' ); +$wpdb->delete( $jobs, array( 'job_id' => $empty_identity['job_id'] ) ); + +// A due job whose linked post has no identity (empty or missing) while the job has one is kept whole. +foreach ( array( 'missing' => null, 'empty' => '' ) as $kind => $value ) { + $blank = ct_test_aged_job( "ret-blank-post-{$kind}.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_make_due( $blank ); + if ( null === $value ) { + delete_post_meta( $blank['request_id'], '_ct_scan_identity' ); + } else { + update_post_meta( $blank['request_id'], '_ct_scan_identity', $value ); + } + ct_test_maintain(); + $br = ct_scan_row( $blank['job_id'] ); + ct_test_assert( $br && get_post( $blank['request_id'] ) && 'fixture@example.com' === $br->contact_email && null === $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $blank['job_id'] ) ), "A job was (partly) deleted although its linked post's identity is {$kind}." ); + $wpdb->delete( $jobs, array( 'job_id' => $blank['job_id'] ) ); +} + +// Restored rows that cannot be acted on (held, leased) never starve a later restored row in the tombstone loop. +$restored_base = array( 'target_origin' => 'https://ret-starve.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-starve.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => ct_test_old( '-1 hour' ) ); +for ( $i = 0; $i < 56; $i++ ) { + $extra = 9 === $i % 10 ? array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+1 hour' ) ) : array( 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ); + $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 888000100 + $i, 'request_id' => 889000100 + $i ), $extra ) ); + $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 888000100 + $i, 'request_id' => 889000100 + $i, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); +} +$wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 888000200, 'request_id' => 889000200 ) ) ); +$wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 888000200, 'request_id' => 889000200, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); +ct_scan_retention_run( true ); +$starve_left = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$jobs} WHERE job_id BETWEEN 888000100 AND 888000155" ); +ct_test_assert( ! ct_scan_row( 888000200 ) && 56 === $starve_left, "Held or leased restored rows starved a later restored row, or were deleted ($starve_left of 56 kept)." ); +$wpdb->query( "DELETE FROM {$jobs} WHERE job_id BETWEEN 888000100 AND 888000200" ); +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE job_id BETWEEN 888000100 AND 888000200' ); + // ---- Second round: floors, clock, ordering, locks ----------------------------------------------------------------- // Evidence retention for non-legacy versions can never be configured below six years; legacy never below three. @@ -560,8 +644,8 @@ function ct_test_make_due( $job ) { ct_test_assert( ct_test_exists( $raise_future ) && ct_test_exists( $raise_legacy ) && $rf->retain_until === ct_scan_row( $raise_future['job_id'] )->retain_until && $rl->retain_until === ct_scan_row( $raise_legacy['job_id'] )->retain_until, 'An extended deletion date was shortened when the configuration went back.' ); // Leap days: a period that starts on 29 February ends on 1 March, never on 28 February. -$leap_r2 = (object) array( 'status' => 'failed', 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-29 12:00:00', 'created_at' => '2024-02-29 12:00:30', 'closed_at' => '2024-03-02 00:00:00', 'retain_until' => '2027-03-01 12:00:00' ); -$leap_new = (object) array( 'status' => 'failed', 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2027-06-01-r3', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2030-03-01 12:00:00' ); +$leap_r2 = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-29 12:00:00', 'created_at' => '2024-02-29 12:00:30', 'closed_at' => '2024-03-02 00:00:00', 'retain_until' => '2027-03-01 12:00:00' ); +$leap_new = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2027-06-01-r3', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2030-03-01 12:00:00' ); ct_test_assert( '2027-03-01 12:00:00' === ct_scan_retain_until( $leap_r2 ) && '2027-03-01 12:00:00' === ct_scan_required_retain_until( $leap_r2 ) && '2030-03-01 12:00:00' === ct_scan_retain_until( $leap_new ), 'A period starting on 29 February does not end on 1 March.' ); ct_test_assert( ! ct_scan_deletion_allowed( $leap_r2, '2027-02-28 23:59:59' ) && ct_scan_deletion_allowed( $leap_r2, '2027-03-01 12:00:00' ) && ! ct_scan_deletion_allowed( $leap_new, '2030-02-28 23:59:59' ) && ct_scan_deletion_allowed( $leap_new, '2030-03-01 12:00:00' ), 'A leap-day period allowed deletion early (or never).' ); $leap_r2->retain_until = '2027-02-28 12:00:00'; From 97f834d5faa4eb6fbec0c4d884b4001048b9c248 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Fri, 23 Oct 2026 16:18:31 -0400 Subject: [PATCH 7/9] Tombstone loop: resume from a stored cursor instead of skipping failed rows Excluding rows whose retention deletion had failed (retention_checked_at) stopped the tombstone loop from finishing a job whose post deletion failed on the previous run (integration test). Failed rows are now attempted again as before; fairness comes from paging from where the previous run stopped, wrapping around once, so rows that keep failing cannot starve later ones. Held and leased rows stay excluded in SQL. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/WORDPRESS.md | 7 ++-- integrations/wordpress/ct-scan.php | 56 +++++++++++++++++------------- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 2732f7f..13d4039 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -610,9 +610,10 @@ queue page): reach a live record). A restored job row that holds a lease (a leased or running scan, or a control check in progress) is deleted again only after that bounded lease ends, so the one-at-a-time gate is never freed while its - traffic may be in flight. Restored rows that cannot be acted on (held, leased, - or whose deletion failed in the last day) are skipped in the query and the - candidates are paged, so they cannot hide later restored rows. Jobs created before the identity existed are given one + traffic may be in flight. Restored rows that cannot be acted on (held or + leased) are skipped in the query, and the candidates are paged from where the + previous run stopped (wrapping around), so neither skipped rows nor rows whose + deletion keeps failing can hide later restored rows. Jobs created before the identity existed are given one (adopting what their request already carries) by the next maintenance run. Tombstones are kept 120 days, longer than the 90-day backup rotation, then purged. The scope MAC is keyed with `wp_salt( 'secure_auth' )`: rotating that salt (or `SECURE_AUTH_KEY`/`SECURE_AUTH_SALT`) stops existing diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 485784f..8e05062 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -1324,39 +1324,49 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu $deletions_left = ct_scan_retention_batch_limit(); } // Candidates by numeric ids and creation time; the keyed scope MAC is compared in PHP, under the lock. - // Rows that cannot be acted on now are excluded in SQL (held or in grace, holding a lease, or whose - // deletion failed within the last day), and the candidates are paged by key, so rows that are skipped - // can never starve later restored rows. + // Rows that cannot be acted on now (held or in grace, holding a lease) are excluded in SQL. The rest are + // paged by key from where the previous run stopped, wrapping around once, so rows that are skipped or + // whose deletion keeps failing can never starve later restored rows. $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); - $recheck = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-1 day' )->format( 'Y-m-d H:i:s' ); - $after_job = 0; - $after_stone = 0; - for ( $page = 0; $page < 20; $page++ ) { + $cursor = max( 0, (int) get_option( 'ct_scan_tombstone_cursor', 0 ) ); + $after_job = $cursor; + $after_id = 0; + $upper = PHP_INT_MAX; + $wrapped = 0 === $cursor; + $finished = false; + for ( $page = 0; $page < 40; $page++ ) { $ghosts = $wpdb->get_results( $wpdb->prepare( 'SELECT j.job_id, t.tombstone_id, t.scope_mac, t.identity FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.created_at = j.created_at' - . ' WHERE (j.job_id > %d OR (j.job_id = %d AND t.tombstone_id > %d))' + . ' WHERE (j.job_id > %d OR (j.job_id = %d AND t.tombstone_id > %d)) AND j.job_id <= %d' . " AND (j.hold_reason IS NULL OR (j.hold_until >= '2020-01-01 00:00:00' AND j.hold_until <= %s))" . " AND j.status NOT IN ('leased','running') AND NOT (j.status = 'awaiting_control' AND COALESCE(j.control_state, '') = 'checking')" - . ' AND (j.retention_checked_at IS NULL OR j.retention_checked_at < %s)' . ' ORDER BY j.job_id ASC, t.tombstone_id ASC LIMIT 50', $after_job, $after_job, - $after_stone, - $grace_edge, - $recheck + $after_id, + $upper, + $grace_edge ) ); if ( empty( $ghosts ) ) { - break; + if ( $wrapped ) { + $finished = true; + break; + } + // Reached the end: start again from the beginning, up to where this run started. + $wrapped = true; + $after_job = 0; + $after_id = 0; + $upper = $cursor; + continue; } foreach ( (array) $ghosts as $ghost ) { - $after_job = (int) $ghost->job_id; - $after_stone = (int) $ghost->tombstone_id; - if ( $deletions_left < 1 || microtime( true ) > $deadline ) { - return; - } - if ( ! ct_scan_lock_acquire() ) { + if ( $deletions_left < 1 || microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { + // Out of budget or time, or the queue is busy: the next run continues from here. + update_option( 'ct_scan_tombstone_cursor', (string) $after_job, false ); return; } + $after_job = (int) $ghost->job_id; + $after_id = (int) $ghost->tombstone_id; try { $row = ct_scan_row( (int) $ghost->job_id ); // A restored row that holds a live lease (a scan, or a control check in progress) is left until the @@ -1364,17 +1374,15 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu // target traffic for it may still be in flight. Re-checked here under the lock. if ( $row && hash_equals( (string) $ghost->scope_mac, ct_scan_tombstone_mac( $row->scope_hash ) ) && ! ct_scan_job_on_hold( $row, $now ) && ! ct_scan_job_holds_lease( $row ) ) { --$deletions_left; - // A failure leaves the row (retried after a day, so it cannot take the budget of every run); - // it never stops the other candidates. - if ( ! ct_scan_delete_job_records( $row, 'restored', (string) $ghost->identity ) ) { - $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); - } + // A failure leaves the row (and is retried next run); it never stops the other candidates. + ct_scan_delete_job_records( $row, 'restored', (string) $ghost->identity ); } } finally { ct_scan_lock_release(); } } } + update_option( 'ct_scan_tombstone_cursor', $finished ? '0' : (string) $after_job, false ); // A restored request post whose job row was not restored: matched in SQL on the opaque identity // (never on recyclable numeric ids alone), and only where no job row currently uses that request or // job id, so a live record can never be reached. Re-checked under the lock before each deletion. From 2a62ccfb4bcc9baa5fce6c34d0f9b5092c4b606e Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Fri, 23 Oct 2026 16:35:05 -0400 Subject: [PATCH 8/9] Retention: release only the hold shown; identity ownership; restored-post paging - Blocker: a stale "Release hold" form could end a newer hold. The form now carries the hold's identity (purpose, expiry, setter, when set) and the release is a compare-and-set on those values with hold_released_at IS NULL; a mismatch or 0 rows returns hold_changed with a plain admin message. - Identity backfill adopts or writes a request post's identity only when the post's _ct_scan_job_id names the job; otherwise both stay unchanged (kept). The backfill pages from a stored cursor so such rows cannot starve it. - Restored-post tombstone loop: the job-id match is in SQL, candidates are paged by tombstone id from a stored cursor with wrap-around, and the job-row loop leaves a fifth of the shared budget for it. - The clock sanity check runs before closure stamping, so an untrusted clock's "now" is never stamped. - Retention years above 100 are clamped (logged once) so derived dates stay below the 2200 validity bound; floors unchanged. - Tests: stale release replay, backfill ownership, refused/foreign restored posts vs an older one (wrap-around), no stamping on a bad clock, year cap; the starvation fixture uses a non-colliding id range and asserts inserts. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/WORDPRESS.md | 29 +++- integrations/wordpress/ct-scan.php | 187 +++++++++++++++------ integrations/wordpress/tests/retention.php | 111 ++++++++++-- 3 files changed, 257 insertions(+), 70 deletions(-) diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 13d4039..021b1c2 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -540,7 +540,8 @@ queue page): configuration; a version that cannot be parsed gets the longer period. The configured `evidence_retention_years` is floored at **six** for non-legacy versions: a word, null, a boolean, 0 or a value below six means six, never - less. + less. A value above 100 years (which would push the date past 2200, where no + date is trusted) is clamped to 100 years and logged once. - **`retain_until` can be extended, never shortened.** When a job's stored date comes due, it is compared with the date required today: the period derived from the row under the current configuration, and never less than three years @@ -566,14 +567,18 @@ queue page): clock and the database clock (`UTC_TIMESTAMP()`) disagree by more than five minutes, or while the server clock is earlier than a stored job timestamp (a clock jumped backwards, a bad restore). The problem is logged once (not on - every run) until the clocks agree again, and only the non-destructive steps - proceed. A forward jump of both clocks together cannot be detected. Each run + every run) until the clocks agree again; while it lasts no closure, deletion + date or examination time is stamped either (a wrong "now" is never recorded), + and only the reopening of non-terminal jobs proceeds. A forward jump of both clocks together cannot be detected. Each run makes at most 50 deletion attempts in total, shared by the evidence step and the tombstone clean-up. A job judged doubtful is looked at again at most daily (`retention_checked_at`), so doubtful rows cannot hide due ones, and the queue lock is held briefly. A job that has no opaque identity yet (created before it - existed and not yet reached by the backfill, which handles 100 jobs per run) is - kept until it has one. A single implausible far-future timestamp on any job + existed and not yet reached by the backfill, which handles 100 jobs per run + from a stored cursor) is kept until it has one. The backfill adopts or writes + the request post's identity only when that post names this job + (`_ct_scan_job_id`); a post that names another job is left alone and the job + stays without identity, so it is kept. A single implausible far-future timestamp on any job (later than now, before 2200) halts every deletion until it is corrected; this is logged once, not on every run. - **Narrow preservation hold** (admin queue): one job, a stated purpose of 20 to @@ -589,7 +594,10 @@ queue page): POST) is refused and changes nothing, so it can neither shorten the hold nor erase its purpose and setter. To change a hold, release it first (the job stays protected through the grace period), then place the new one; a new hold never - ends earlier than the released one. + ends earlier than the released one. The "Release hold" form carries the + identity of the hold it shows (purpose, expiry, setter, when set); a release + is a compare-and-set on those values, so a stale form can never end a newer + hold that replaced it ("hold changed: reload the queue"). - **Retention runs outside the queue lock.** Only one retention run at a time (its own advisory lock, throttled to about once a minute on worker polls that hand out no job, so a lease is never delayed by it, and run again on the admin @@ -605,7 +613,7 @@ queue page): matches on its ids, scope and creation time, and its request post is deleted with it only if the post carries the tombstone's opaque identity (otherwise the post id was recycled and belongs to another record); a restored request post matches - only on its opaque identity and only when no job row currently uses that request + only on its opaque identity and the job id it names, and only when no job row currently uses that request or job id (auto-increment ids can be recycled after a restore and must never reach a live record). A restored job row that holds a lease (a leased or running scan, or a control check in progress) is deleted again only after @@ -613,8 +621,11 @@ queue page): traffic may be in flight. Restored rows that cannot be acted on (held or leased) are skipped in the query, and the candidates are paged from where the previous run stopped (wrapping around), so neither skipped rows nor rows whose - deletion keeps failing can hide later restored rows. Jobs created before the identity existed are given one - (adopting what their request already carries) by the next maintenance run. Tombstones are kept 120 days, longer than the 90-day backup + deletion keeps failing can hide later restored rows. Restored request posts are + paged the same way (by tombstone, from a stored cursor), and the job-row loop + leaves a fifth of the shared budget for them, so posts whose deletion is + refused cannot hide others either. Jobs created before the identity existed are given one + (adopting what their request already carries, if that request names the job) by the next maintenance run. Tombstones are kept 120 days, longer than the 90-day backup rotation, then purged. The scope MAC is keyed with `wp_salt( 'secure_auth' )`: rotating that salt (or `SECURE_AUTH_KEY`/`SECURE_AUTH_SALT`) stops existing tombstones from recognising restored job rows (restored request posts are still diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 8e05062..2dbc25e 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -905,16 +905,31 @@ function ct_scan_valid_datetime( $value ) { return true; } +/** The longest retention period honoured, so every derived date stays a valid (pre-2200) date. */ +function ct_scan_max_retention_years() { + return 100; +} + /** * A retention period in whole years, never below its floor: 3 for r2-and-earlier acceptances, 6 for * everything else. A value that is not a plain positive number (a word, null, a boolean, an array, 0, * negative) means "the floor", never "shorter", and fractions round down but never below the floor. + * A value above ct_scan_max_retention_years() (which would push the deletion date past 2200, where no + * date is trusted and the record would be kept forever) is clamped to that maximum and logged once. */ function ct_scan_clamp_years( $years, $floor = 3 ) { + static $warned = false; $floor = max( 3, (int) $floor ); if ( ( ! is_int( $years ) && ! is_float( $years ) && ! ( is_string( $years ) && is_numeric( $years ) ) ) || ! is_finite( (float) $years ) ) { return $floor; } + if ( (float) $years > ct_scan_max_retention_years() ) { + if ( ! $warned ) { + $warned = true; + error_log( 'ct-scan: a configured retention period above ' . ct_scan_max_retention_years() . ' years was clamped to ' . ct_scan_max_retention_years() . ' years.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + return max( $floor, ct_scan_max_retention_years() ); + } return max( $floor, (int) $years ); } @@ -1232,26 +1247,47 @@ function ct_scan_retention_steps( $now ) { $table = ct_scan_jobs_table(); $terminal = ct_scan_terminal_sql(); $deadline = microtime( true ) + 3.0; - // 0. A job that can run again is not closed: forget any closure stamped on it. + // 0. A job that can run again is not closed: forget any closure stamped on it. (Clears dates only: keeps data.) $wpdb->query( "UPDATE {$table} SET closed_at = NULL, retain_until = NULL WHERE status NOT IN ({$terminal}) AND (closed_at IS NOT NULL OR retain_until IS NOT NULL)" ); + // Everything below stamps "now" or deletes. Refuse all of it if the clock cannot be trusted, so a wrong + // clock's "now" is never stamped as a closure, a deletion date or an examination time. + $sane = ct_scan_clock_is_sane( $now ); + ct_scan_note_clock_state( $sane ); + if ( ! $sane ) { + return; + } // 1. Closure is explicit: the first time a job is seen terminal it is stamped. $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET closed_at = COALESCE(finished_at, %s) WHERE closed_at IS NULL AND status IN ({$terminal})", $now ) ); // A row judged doubtful is re-examined at most daily, so doubtful rows can never starve the rest. $recheck = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-1 day' )->format( 'Y-m-d H:i:s' ); // 1b. Jobs that predate the opaque identity get one (and so does their request) so a restored request - // can be recognised after deletion. - $anonymous = $wpdb->get_results( "SELECT job_id,request_id,identity FROM {$table} WHERE identity IS NULL OR identity = '' ORDER BY job_id ASC LIMIT 100" ); + // can be recognised after deletion. The request's identity is adopted or written only when that post + // provably belongs to this job (its _ct_scan_job_id is this job's id); a request post that names another + // job (a recycled id) is left alone, and so is the job, which then stays without identity and is kept. + // Paged from a stored cursor (wrapping around) so such rows can never starve later ones. + $identity_cursor = max( 0, (int) get_option( 'ct_scan_identity_cursor', 0 ) ); + $anonymous = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,request_id,identity FROM {$table} WHERE (identity IS NULL OR identity = '') AND job_id > %d ORDER BY job_id ASC LIMIT 100", $identity_cursor ) ); foreach ( (array) $anonymous as $row ) { - // Adopt an identity the request already carries; otherwise mint one for both. - $carried = ct_scan_meta_string( (int) $row->request_id, '_ct_scan_identity' ); - $identity = 1 === preg_match( '/\A[a-f0-9]{32}\z/', $carried ) ? $carried : bin2hex( random_bytes( 16 ) ); - if ( 1 === $wpdb->update( $table, array( 'identity' => $identity ), array( 'job_id' => (int) $row->job_id, 'identity' => $row->identity ), array( '%s' ), array( '%d', '%s' ) ) && $identity !== $carried ) { - $post = get_post( (int) $row->request_id ); - if ( $post && 'ct_promo_request' === $post->post_type ) { + $identity_cursor = (int) $row->job_id; + clean_post_cache( (int) $row->request_id ); + $post = get_post( (int) $row->request_id ); + if ( $post && 'ct_promo_request' === $post->post_type ) { + if ( (string) (int) $row->job_id !== ct_scan_meta_string( (int) $row->request_id, '_ct_scan_job_id' ) ) { + continue; // The post names another job (or none): touch neither; the job is kept. + } + // Adopt an identity the request already carries; otherwise mint one for both. + $carried = ct_scan_meta_string( (int) $row->request_id, '_ct_scan_identity' ); + $identity = 1 === preg_match( '/\A[a-f0-9]{32}\z/', $carried ) ? $carried : bin2hex( random_bytes( 16 ) ); + if ( 1 === $wpdb->update( $table, array( 'identity' => $identity ), array( 'job_id' => (int) $row->job_id, 'identity' => $row->identity ), array( '%s' ), array( '%d', '%s' ) ) && $identity !== $carried ) { update_post_meta( (int) $row->request_id, '_ct_scan_identity', $identity ); } + } else { + // No request post of ours under that id: the job gets an identity of its own; no post is touched + // (ct_scan_delete_job_records() never deletes a post that is not this job's request). + $wpdb->update( $table, array( 'identity' => bin2hex( random_bytes( 16 ) ) ), array( 'job_id' => (int) $row->job_id, 'identity' => $row->identity ), array( '%s' ), array( '%d', '%s' ) ); } } + update_option( 'ct_scan_identity_cursor', count( (array) $anonymous ) < 100 ? '0' : (string) $identity_cursor, false ); // 2. The evidence deletion date is set at closure; later it may only be extended (step 4), never shortened. // An untrustworthy base leaves it NULL (kept). $unset = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY job_id ASC LIMIT 200", $recheck ) ); @@ -1263,12 +1299,6 @@ function ct_scan_retention_steps( $now ) { $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); } } - // Everything below deletes. Refuse all of it if the clock cannot be trusted. - $sane = ct_scan_clock_is_sane( $now ); - ct_scan_note_clock_state( $sane ); - if ( ! $sane ) { - return; - } // 3. Findings: twelve months after closure (366 days, so a leap day cannot make it early), unless held. $findings_cutoff = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-366 days' )->format( 'Y-m-d H:i:s' ); $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); @@ -1334,7 +1364,10 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu $upper = PHP_INT_MAX; $wrapped = 0 === $cursor; $finished = false; - for ( $page = 0; $page < 40; $page++ ) { + // Part of the budget is left for the restored-post loop below, so restored job rows whose deletion keeps + // failing can never starve it. + $post_reserve = intdiv( max( 0, (int) $deletions_left ), 5 ); + for ( $page = 0; $page < 40 && ! $finished; $page++ ) { $ghosts = $wpdb->get_results( $wpdb->prepare( 'SELECT j.job_id, t.tombstone_id, t.scope_mac, t.identity FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.created_at = j.created_at' . ' WHERE (j.job_id > %d OR (j.job_id = %d AND t.tombstone_id > %d)) AND j.job_id <= %d' @@ -1360,8 +1393,12 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu continue; } foreach ( (array) $ghosts as $ghost ) { - if ( $deletions_left < 1 || microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { - // Out of budget or time, or the queue is busy: the next run continues from here. + if ( $deletions_left <= $post_reserve ) { + // This loop's share of the budget is spent: the next run continues from here. + break 2; + } + if ( microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { + // Out of time, or the queue is busy: the next run continues from here. update_option( 'ct_scan_tombstone_cursor', (string) $after_job, false ); return; } @@ -1384,36 +1421,64 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu } update_option( 'ct_scan_tombstone_cursor', $finished ? '0' : (string) $after_job, false ); // A restored request post whose job row was not restored: matched in SQL on the opaque identity - // (never on recyclable numeric ids alone), and only where no job row currently uses that request or - // job id, so a live record can never be reached. Re-checked under the lock before each deletion. - $postmeta = $wpdb->postmeta; - $restored = $wpdb->get_results( - 'SELECT t.job_id, t.request_id, t.identity FROM ' . $stones . ' t' - . ' INNER JOIN ' . $wpdb->posts . " p ON p.ID = t.request_id AND p.post_type = 'ct_promo_request'" - . ' INNER JOIN ' . $postmeta . " m ON m.post_id = t.request_id AND m.meta_key = '_ct_scan_identity' AND m.meta_value = t.identity" - . " WHERE t.identity IS NOT NULL AND t.identity <> ''" - . ' AND NOT EXISTS (SELECT 1 FROM ' . $table . ' j WHERE j.request_id = t.request_id OR j.job_id = t.job_id)' - . ' ORDER BY t.tombstone_id DESC LIMIT 50' - ); - foreach ( (array) $restored as $stone ) { - if ( $deletions_left < 1 || microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { - return; + // (never on recyclable numeric ids alone) AND on the job id the post names, and only where no job row + // currently uses that request or job id, so a live record can never be reached. Re-checked under the + // lock before each deletion. Paged by tombstone id from where the previous run stopped, wrapping around + // once, so posts whose deletion is refused (a filter, the database) can never starve later ones. + $postmeta = $wpdb->postmeta; + $post_cursor = max( 0, (int) get_option( 'ct_scan_restored_post_cursor', 0 ) ); + $after_stone = $post_cursor; + $upper_stone = PHP_INT_MAX; + $post_wrapped = 0 === $post_cursor; + $post_done = false; + for ( $page = 0; $page < 40 && ! $post_done; $page++ ) { + $restored = $wpdb->get_results( $wpdb->prepare( + 'SELECT t.tombstone_id, t.job_id, t.request_id, t.identity FROM ' . $stones . ' t' + . ' INNER JOIN ' . $wpdb->posts . " p ON p.ID = t.request_id AND p.post_type = 'ct_promo_request'" + . ' INNER JOIN ' . $postmeta . " m ON m.post_id = t.request_id AND m.meta_key = '_ct_scan_identity' AND m.meta_value = t.identity" + . ' INNER JOIN ' . $postmeta . " k ON k.post_id = t.request_id AND k.meta_key = '_ct_scan_job_id' AND k.meta_value = t.job_id" + . " WHERE t.identity IS NOT NULL AND t.identity <> '' AND t.tombstone_id > %d AND t.tombstone_id <= %d" + . ' AND NOT EXISTS (SELECT 1 FROM ' . $table . ' j WHERE j.request_id = t.request_id OR j.job_id = t.job_id)' + . ' GROUP BY t.tombstone_id, t.job_id, t.request_id, t.identity ORDER BY t.tombstone_id ASC LIMIT 50', + $after_stone, + $upper_stone + ) ); + if ( empty( $restored ) ) { + if ( $post_wrapped ) { + $post_done = true; + break; + } + // Reached the end: start again from the beginning, up to where this run started. + $post_wrapped = true; + $after_stone = 0; + $upper_stone = $post_cursor; + continue; } - try { - $live = $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$table} WHERE request_id = %d OR job_id = %d LIMIT 1", (int) $stone->request_id, (int) $stone->job_id ) ); - clean_post_cache( (int) $stone->request_id ); - if ( ! $live && hash_equals( (string) $stone->identity, ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_identity' ) ) && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { - --$deletions_left; - if ( ! wp_delete_post( (int) $stone->request_id, true ) ) { - // Refused (a filter or the database): the post is kept and retried next run. This attempt - // has been counted, and the loop moves on, so one stuck post can neither spin nor block the rest. - clean_post_cache( (int) $stone->request_id ); + foreach ( (array) $restored as $stone ) { + if ( $deletions_left < 1 || microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { + // Out of budget or time, or the queue is busy: the next run continues from here. + update_option( 'ct_scan_restored_post_cursor', (string) $after_stone, false ); + return; + } + $after_stone = (int) $stone->tombstone_id; + try { + $live = $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$table} WHERE request_id = %d OR job_id = %d LIMIT 1", (int) $stone->request_id, (int) $stone->job_id ) ); + clean_post_cache( (int) $stone->request_id ); + if ( ! $live && hash_equals( (string) $stone->identity, ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_identity' ) ) && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { + --$deletions_left; + if ( ! wp_delete_post( (int) $stone->request_id, true ) ) { + // Refused (a filter or the database): the post is kept and retried on a later pass. This + // attempt has been counted and the cursor moves on, so one stuck post can neither spin nor + // block the rest. + clean_post_cache( (int) $stone->request_id ); + } } + } finally { + ct_scan_lock_release(); } - } finally { - ct_scan_lock_release(); } } + update_option( 'ct_scan_restored_post_cursor', $post_done ? '0' : (string) $after_stone, false ); } /** @@ -1467,7 +1532,21 @@ function ct_scan_admin_set_hold( $job_id, $reason, $days ) { return true; } -function ct_scan_admin_release_hold( $job_id ) { +/** + * The identity of one specific hold (its purpose, expiry, setter and when it was set), carried by the + * "Release hold" form, so a release can only ever end the hold the administrator was looking at. + */ +function ct_scan_hold_identity( $row ) { + return hash( 'sha256', implode( "\n", array( 'ct-scan-hold-v1', (string) $row->job_id, (string) $row->hold_reason, (string) $row->hold_until, (string) $row->hold_set_at, (string) $row->hold_set_by ) ) ); +} + +/** + * Release the hold identified by $expected_hold (ct_scan_hold_identity() of the hold shown to the + * administrator). A stale form (the hold was released and a different one placed since, by another + * administrator or in another tab) is refused with 'hold_changed' and changes nothing. The write is a + * compare-and-set on the hold's values as read under the queue lock. + */ +function ct_scan_admin_release_hold( $job_id, $expected_hold = '' ) { global $wpdb; $user = wp_get_current_user(); if ( ! $user || ! $user->exists() || ! current_user_can( 'manage_options' ) ) { @@ -1481,15 +1560,27 @@ function ct_scan_admin_release_hold( $job_id ) { if ( ! $row || empty( $row->hold_reason ) || ! empty( $row->hold_released_at ) ) { return new WP_Error( 'hold_unavailable', 'This job has no hold to release.' ); } + if ( ! is_string( $expected_hold ) || 1 !== preg_match( '/\A[a-f0-9]{64}\z/', $expected_hold ) || ! hash_equals( ct_scan_hold_identity( $row ), $expected_hold ) ) { + return new WP_Error( 'hold_changed', 'The hold changed since the queue was loaded; it was not released.' ); + } // Releasing ends the hold now, it does not erase it: the purpose stays (with who released it and // when), and the job keeps the same three-day grace as a hold that expires. A hold that already // expired keeps its own (earlier) end, so a release never lengthens it. $now = ct_scan_utc_now(); $until = ( ct_scan_valid_datetime( $row->hold_until ) && $row->hold_until < $now ) ? $row->hold_until : $now; - $updated = $wpdb->update( ct_scan_jobs_table(), array( 'hold_until' => $until, 'hold_released_by' => (int) $user->ID, 'hold_released_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%d', '%s' ), array( '%d' ) ); + $updated = $wpdb->update( + ct_scan_jobs_table(), + array( 'hold_until' => $until, 'hold_released_by' => (int) $user->ID, 'hold_released_at' => $now ), + array( 'job_id' => (int) $row->job_id, 'hold_reason' => $row->hold_reason, 'hold_until' => $row->hold_until, 'hold_set_at' => $row->hold_set_at, 'hold_set_by' => $row->hold_set_by, 'hold_released_at' => null ), + array( '%s', '%d', '%s' ), + array( '%d', '%s', '%s', '%s', '%d', '%s' ) + ); if ( false === $updated ) { return new WP_Error( 'hold_write_failed', 'The hold could not be released.' ); } + if ( 1 !== $updated ) { + return new WP_Error( 'hold_changed', 'The hold changed since the queue was loaded; it was not released.' ); + } } finally { ct_scan_lock_release(); } @@ -3103,7 +3194,7 @@ function ct_scan_admin_action_handler() { } elseif ( 'set_hold' === $action ) { $result = ct_scan_admin_set_hold( $job_id, isset( $_POST['ct_scan_hold_reason'] ) && is_string( $_POST['ct_scan_hold_reason'] ) ? wp_unslash( $_POST['ct_scan_hold_reason'] ) : '', isset( $_POST['ct_scan_hold_days'] ) && is_scalar( $_POST['ct_scan_hold_days'] ) ? wp_unslash( $_POST['ct_scan_hold_days'] ) : 0 ); } elseif ( 'release_hold' === $action ) { - $result = ct_scan_admin_release_hold( $job_id ); + $result = ct_scan_admin_release_hold( $job_id, isset( $_POST['ct_scan_hold_identity'] ) && is_string( $_POST['ct_scan_hold_identity'] ) ? sanitize_key( wp_unslash( $_POST['ct_scan_hold_identity'] ) ) : '' ); } elseif ( 'authority_review' === $action ) { $result = ct_scan_admin_record_authority_review( $job_id, isset( $_POST['ct_scan_authority_note'] ) && is_string( $_POST['ct_scan_authority_note'] ) ? wp_unslash( $_POST['ct_scan_authority_note'] ) : '' ); } else { @@ -3177,7 +3268,7 @@ function ct_scan_admin_page() { $notice = sanitize_key( wp_unslash( $_GET['ct_scan_notice'] ) ); $messages = array( 'hold_active' => 'Not changed: this job already has a preservation hold. A second hold cannot replace or shorten it; release the existing hold first (the records stay protected through the grace period), then place the new one.', - 'hold_changed' => 'Not changed: the hold was modified at the same time. Reload the queue and try again.', + 'hold_changed' => 'Not changed: the hold was modified since this page was loaded (for example, released and replaced by another administrator). Reload the queue to see the current hold, then try again.', ); echo '

' . esc_html( $messages[ $notice ] ?? $notice ) . '

'; } @@ -3217,7 +3308,7 @@ function ct_scan_admin_page() { } $on_hold = ct_scan_job_on_hold( $row, ct_scan_utc_now() ); if ( $on_hold && empty( $row->hold_released_at ) ) { - echo '

Preservation hold until ' . esc_html( $row->hold_until ) . ' UTC: ' . esc_html( $row->hold_reason ) . '

' . ct_scan_admin_form( (int) $row->job_id, 'release_hold', 'Release hold', ' ' ); + echo '

Preservation hold until ' . esc_html( $row->hold_until ) . ' UTC: ' . esc_html( $row->hold_reason ) . '

' . ct_scan_admin_form( (int) $row->job_id, 'release_hold', 'Release hold', ' ' ); } else { if ( $on_hold ) { // Released, still inside the grace period: show it, and allow a new hold. diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 629ebb1..1b44bc8 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -8,7 +8,7 @@ $jobs = ct_scan_jobs_table(); ct_test_purge_fixtures(); // Leftovers of an aborted earlier run of this script. -$wpdb->query( "DELETE FROM {$jobs} WHERE job_id >= 888000000" ); +$wpdb->query( "DELETE FROM {$jobs} WHERE job_id >= 888000000 OR job_id BETWEEN 887000000 AND 887999999" ); $wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() ); $request_ids = array(); add_filter( 'pre_wp_mail', '__return_true' ); @@ -285,7 +285,8 @@ function ct_test_exists( $job ) { // Release works; a damaged hold (purpose without expiry) preserves rather than deletes. $rel = ct_test_aged_job( 'ret-release.example.net', 'failed', '-3 years -1 day', '-1 day' ); ct_scan_admin_set_hold( $rel['job_id'], 'Preserving records for dispute number 456.', 10 ); -ct_test_assert( true === ct_scan_admin_release_hold( $rel['job_id'] ), 'Release failed.' ); +ct_test_assert( 'hold_changed' === ct_scan_admin_release_hold( $rel['job_id'] )->get_error_code() && 'hold_changed' === ct_scan_admin_release_hold( $rel['job_id'], str_repeat( 'a', 64 ) )->get_error_code() && empty( ct_scan_row( $rel['job_id'] )->hold_released_at ), 'A release without the identity of the hold shown was accepted.' ); +ct_test_assert( true === ct_scan_admin_release_hold( $rel['job_id'], ct_scan_hold_identity( ct_scan_row( $rel['job_id'] ) ) ), 'Release failed.' ); $released = ct_scan_row( $rel['job_id'] ); ct_test_assert( 'Preserving records for dispute number 456.' === $released->hold_reason && $released->hold_until <= ct_scan_utc_now() && $released->hold_until >= ct_test_old( '-1 minute' ) && 1 === (int) $released->hold_released_by && ! empty( $released->hold_released_at ), 'Release did not end the hold now (keeping its purpose) or did not record who released it.' ); ct_test_assert( 'hold_unavailable' === ct_scan_admin_release_hold( $rel['job_id'] )->get_error_code() && 'hold_unavailable' === ct_scan_admin_release_hold( 999999999 )->get_error_code() && 'hold_unavailable' === ct_scan_admin_release_hold( 0 )->get_error_code(), 'Releasing a hold that does not exist reported success.' ); @@ -302,7 +303,7 @@ function ct_test_exists( $job ) { ct_scan_admin_set_hold( $lapsed['job_id'], 'Preserving records for dispute number 789.', 10 ); $lapsed_until = ct_test_old( '-2 days' ); $wpdb->update( $jobs, array( 'hold_until' => $lapsed_until ), array( 'job_id' => $lapsed['job_id'] ) ); -ct_test_assert( true === ct_scan_admin_release_hold( $lapsed['job_id'] ) && $lapsed_until === ct_scan_row( $lapsed['job_id'] )->hold_until, 'Releasing an expired hold lengthened it.' ); +ct_test_assert( true === ct_scan_admin_release_hold( $lapsed['job_id'], ct_scan_hold_identity( ct_scan_row( $lapsed['job_id'] ) ) ) && $lapsed_until === ct_scan_row( $lapsed['job_id'] )->hold_until, 'Releasing an expired hold lengthened it.' ); wp_set_current_user( 0 ); ct_test_assert( 'hold_forbidden' === ct_scan_admin_release_hold( $damaged['job_id'] )->get_error_code() && ct_test_exists( $damaged ) && ! empty( ct_scan_row( $damaged['job_id'] )->hold_reason ), 'An anonymous caller released a hold.' ); @@ -402,7 +403,7 @@ function ct_test_exists( $job ) { // Placing a new hold clears the audit of an earlier release. $reheld = ct_test_aged_job( 'ret-reheld.example.net', 'failed', '-1 day', '-1 hour' ); ct_scan_admin_set_hold( $reheld['job_id'], 'First preservation purpose for this job.', 10 ); -ct_scan_admin_release_hold( $reheld['job_id'] ); +ct_scan_admin_release_hold( $reheld['job_id'], ct_scan_hold_identity( ct_scan_row( $reheld['job_id'] ) ) ); ct_scan_admin_set_hold( $reheld['job_id'], 'Second preservation purpose for this job.', 10 ); $rh = ct_scan_row( $reheld['job_id'] ); ct_test_assert( null === $rh->hold_released_by && null === $rh->hold_released_at && 'Second preservation purpose for this job.' === $rh->hold_reason, 'A new hold kept the audit of an earlier release.' ); @@ -430,7 +431,7 @@ function ct_test_exists( $job ) { ct_test_maintain(); ct_test_assert( ct_test_exists( $long_hold ), 'A job in its hold grace period was deleted.' ); // Released (still in grace): a new hold may be placed, and it never ends earlier than the released one. -ct_test_assert( true === ct_scan_admin_release_hold( $long_hold['job_id'] ) && $lh->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until, 'Releasing an expired hold changed its end.' ); +ct_test_assert( true === ct_scan_admin_release_hold( $long_hold['job_id'], ct_scan_hold_identity( $lh ) ) && $lh->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until, 'Releasing an expired hold changed its end.' ); ct_test_assert( true === ct_scan_admin_set_hold( $long_hold['job_id'], 'A different, later purpose for this job.', 30 ), 'A new hold after a release was refused.' ); $lh2 = ct_scan_row( $long_hold['job_id'] ); ct_test_assert( 'A different, later purpose for this job.' === $lh2->hold_reason && (int) $second_admin === (int) $lh2->hold_set_by && $lh2->hold_until > ct_test_old( '+29 days' ) && null === $lh2->hold_released_at, 'A new hold after a release was not recorded with its own purpose and setter.' ); @@ -480,16 +481,16 @@ function ct_test_exists( $job ) { $restored_base = array( 'target_origin' => 'https://ret-starve.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-starve.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => ct_test_old( '-1 hour' ) ); for ( $i = 0; $i < 56; $i++ ) { $extra = 9 === $i % 10 ? array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+1 hour' ) ) : array( 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ); - $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 888000100 + $i, 'request_id' => 889000100 + $i ), $extra ) ); - $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 888000100 + $i, 'request_id' => 889000100 + $i, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); + ct_test_assert( 1 === $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 887000100 + $i, 'request_id' => 886000100 + $i ), $extra ) ), 'Fixture insert of a restored row failed: ' . $wpdb->last_error ); + $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 887000100 + $i, 'request_id' => 886000100 + $i, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); } -$wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 888000200, 'request_id' => 889000200 ) ) ); -$wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 888000200, 'request_id' => 889000200, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); +ct_test_assert( 1 === $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 887000200, 'request_id' => 886000200 ) ) ), 'Fixture insert of a restored row failed: ' . $wpdb->last_error ); +$wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 887000200, 'request_id' => 886000200, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); ct_scan_retention_run( true ); -$starve_left = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$jobs} WHERE job_id BETWEEN 888000100 AND 888000155" ); -ct_test_assert( ! ct_scan_row( 888000200 ) && 56 === $starve_left, "Held or leased restored rows starved a later restored row, or were deleted ($starve_left of 56 kept)." ); -$wpdb->query( "DELETE FROM {$jobs} WHERE job_id BETWEEN 888000100 AND 888000200" ); -$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE job_id BETWEEN 888000100 AND 888000200' ); +$starve_left = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$jobs} WHERE job_id BETWEEN 887000100 AND 887000155" ); +ct_test_assert( ! ct_scan_row( 887000200 ) && 56 === $starve_left, "Held or leased restored rows starved a later restored row, or were deleted ($starve_left of 56 kept)." ); +$wpdb->query( "DELETE FROM {$jobs} WHERE job_id BETWEEN 887000100 AND 887000200" ); +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE job_id BETWEEN 887000100 AND 887000200' ); // ---- Second round: floors, clock, ordering, locks ----------------------------------------------------------------- @@ -503,6 +504,11 @@ function ct_test_exists( $job ) { $p = proc_open( array( PHP_BINARY, __DIR__ . '/retention-years.php' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pp, null, array_merge( getenv(), array( 'CT_TEST_WP_ROOT' => getenv( 'CT_TEST_WP_ROOT' ) ?: '/var/www/html', 'CT_TEST_EVIDENCE_YEARS' => '8' ) ) ); ct_test_assert( '2034-01-01 00:00:00 2028-01-01 00:00:00' === trim( stream_get_contents( $pp[1] ) ), 'A longer configured retention was not honoured.' ); proc_close( $p ); +// A retention period that would push the date past 2200 (where no date is trusted, so "kept forever") is clamped to 100 years. +$p = proc_open( array( PHP_BINARY, __DIR__ . '/retention-years.php' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pp, null, array_merge( getenv(), array( 'CT_TEST_WP_ROOT' => getenv( 'CT_TEST_WP_ROOT' ) ?: '/var/www/html', 'CT_TEST_EVIDENCE_YEARS' => '500' ) ) ); +$capped = trim( stream_get_contents( $pp[1] ) ); +proc_close( $p ); +ct_test_assert( '2126-01-01 00:00:00 2028-01-01 00:00:00' === $capped && 100 === ct_scan_clamp_years( 500, 6 ) && 100 === ct_scan_clamp_years( '1e10', 6 ) && 100 === ct_scan_clamp_years( 100, 6 ) && 99 === ct_scan_clamp_years( 99, 6 ), "An oversized retention period was not clamped to 100 years ($capped)." ); ct_test_assert( 6 === ct_scan_clamp_years( 'six', 6 ) && 6 === ct_scan_clamp_years( null, 6 ) && 6 === ct_scan_clamp_years( 0, 6 ) && 6 === ct_scan_clamp_years( 1, 6 ) && 6 === ct_scan_clamp_years( true, 6 ) && 6 === ct_scan_clamp_years( array( 9 ), 6 ) && 7 === ct_scan_clamp_years( '7', 6 ) && 3 === ct_scan_clamp_years( 0, 1 ), 'The retention floors do not hold.' ); // A deletion is refused when the record appears to have closed before it was created (every other check passes). @@ -517,13 +523,16 @@ function ct_test_exists( $job ) { $clocked = ct_test_aged_job( 'ret-clock.example.net', 'failed', '-3 years -1 day', '-1 day' ); $future_row = ct_test_aged_job( 'ret-future-row.example.net', 'queued', '-1 day', null ); $wpdb->update( $jobs, array( 'created_at' => ct_test_old( '+10 days' ), 'authorization_expires_at' => ct_test_old( '+60 days' ) ), array( 'job_id' => $future_row['job_id'] ) ); +$clock_closing = ct_test_aged_job( 'ret-clock-closing.example.net', 'cancelled', '-1 day', null ); // no finished_at: closure would stamp "now" ct_test_maintain(); ct_test_assert( ct_test_exists( $clocked ) && get_post( $clocked['request_id'] ), 'Evidence was deleted although the clock was earlier than a stored job timestamp.' ); +ct_test_assert( null === ct_scan_row( $clock_closing['job_id'] )->closed_at && null === ct_scan_row( $clocked['job_id'] )->closed_at && null === ct_scan_row( $clocked['job_id'] )->retain_until, 'A closure or deletion date was stamped while the clock could not be trusted.' ); ct_test_assert( ! ct_scan_clock_is_sane( ct_scan_utc_now() ) && ct_scan_clock_is_sane( ct_test_old( '+11 days' ) ), 'The clock sanity check does not compare with the newest stored timestamp.' ); $wpdb->delete( $jobs, array( 'job_id' => $future_row['job_id'] ) ); wp_delete_post( $future_row['request_id'], true ); ct_test_maintain(); ct_test_assert( ! ct_test_exists( $clocked ), 'Deletion did not resume once the clock was consistent.' ); +ct_test_assert( ! empty( ct_scan_row( $clock_closing['job_id'] )->closed_at ), 'Closure was not stamped once the clock was consistent.' ); ct_test_assert( ! ct_scan_clock_is_sane( '1999-01-01 00:00:00' ) && ! ct_scan_clock_is_sane( '2300-01-01 00:00:00' ), 'Absurd clocks were accepted.' ); /** Stamp closure and the deletion date directly, without running a pass (the job is due as soon as retention runs). */ @@ -764,6 +773,82 @@ function ct_test_make_due( $job ) { ct_test_assert( ! ct_test_exists( $gone ), "A restored row was not deleted again after its {$kind} lease ended." ); } +// ---- Fifth review: stale release, identity backfill ownership, restored-post paging ---------------------------- +wp_set_current_user( 1 ); +// A stale "Release hold" form (for hold H1) cannot end a newer hold H2 placed after H1 was released. +$stale = ct_test_aged_job( 'ret-stale-release.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $stale ); +ct_test_assert( true === ct_scan_admin_set_hold( $stale['job_id'], 'First hold H1 for dispute number 1357.', 10 ), 'Fixture hold H1 was refused.' ); +$h1_identity = ct_scan_hold_identity( ct_scan_row( $stale['job_id'] ) ); +ct_test_assert( true === ct_scan_admin_release_hold( $stale['job_id'], $h1_identity ), 'Releasing H1 failed.' ); +ct_test_assert( true === ct_scan_admin_set_hold( $stale['job_id'], 'Second hold H2 for litigation number 2468.', 730 ), 'Fixture hold H2 was refused.' ); +$h2 = ct_scan_row( $stale['job_id'] ); +$replayed = ct_scan_admin_release_hold( $stale['job_id'], $h1_identity ); +$h2_now = ct_scan_row( $stale['job_id'] ); +ct_test_assert( is_wp_error( $replayed ) && 'hold_changed' === $replayed->get_error_code() && $h2->hold_until === $h2_now->hold_until && $h2->hold_reason === $h2_now->hold_reason && null === $h2_now->hold_released_at && null === $h2_now->hold_released_by, 'A stale release form for an earlier hold released a newer hold.' ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $stale ) && get_post( $stale['request_id'] ), 'A due job under a newer hold was deleted after a stale release.' ); +ct_test_assert( true === ct_scan_admin_release_hold( $stale['job_id'], ct_scan_hold_identity( $h2_now ) ), 'The current hold could not be released with its own identity.' ); +$wpdb->delete( $jobs, array( 'job_id' => $stale['job_id'] ) ); + +// The identity backfill never adopts or overwrites the identity of a request post that names another job. +$owner = ct_test_aged_job( 'ret-backfill-owner.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $owner ); +$wpdb->update( $jobs, array( 'identity' => null ), array( 'job_id' => $owner['job_id'] ) ); +update_post_meta( $owner['request_id'], '_ct_scan_job_id', 999999998 ); +update_post_meta( $owner['request_id'], '_ct_scan_identity', str_repeat( 'c', 32 ) ); +ct_test_maintain(); +ct_test_assert( null === ct_scan_row( $owner['job_id'] )->identity && str_repeat( 'c', 32 ) === ct_scan_meta_string( $owner['request_id'], '_ct_scan_identity' ) && ct_test_exists( $owner ) && get_post( $owner['request_id'] ), 'The identity backfill adopted, or the job lost records to, a request post that names another job.' ); +delete_post_meta( $owner['request_id'], '_ct_scan_identity' ); +ct_test_maintain(); +ct_test_assert( null === ct_scan_row( $owner['job_id'] )->identity && '' === ct_scan_meta_string( $owner['request_id'], '_ct_scan_identity' ) && ct_test_exists( $owner ), 'The identity backfill wrote an identity onto a request post that names another job.' ); +update_post_meta( $owner['request_id'], '_ct_scan_job_id', $owner['job_id'] ); +update_post_meta( $owner['request_id'], '_ct_scan_identity', str_repeat( 'c', 32 ) ); +ct_test_maintain(); +ct_test_assert( str_repeat( 'c', 32 ) === $wpdb->get_var( $wpdb->prepare( 'SELECT identity FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $owner['job_id'] ) ) && ! ct_test_exists( $owner ), 'Once the request named its job, the backfill did not adopt its identity (and allow the due deletion).' ); + +// Restored request posts whose deletion is refused, or that name another job, never starve an older restored post. +$stone_row = static function ( $job_id, $post_id, $identity ) { + return array( 'job_id' => $job_id, 'request_id' => $post_id, 'scope_mac' => str_repeat( 'd', 64 ), 'created_at' => ct_test_old( '-2 years' ), 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => $identity ); +}; +$restored_post = static function ( $job_id, $identity ) { + $id = wp_insert_post( array( 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); + update_post_meta( $id, '_ct_scan_job_id', $job_id ); + update_post_meta( $id, '_ct_scan_identity', $identity ); + return (int) $id; +}; +$target_identity = bin2hex( random_bytes( 16 ) ); +$target_post = $restored_post( 887100000, $target_identity ); +ct_test_assert( 1 === $wpdb->insert( ct_scan_tombstones_table(), $stone_row( 887100000, $target_post, $target_identity ) ), 'Fixture tombstone insert failed: ' . $wpdb->last_error ); +$target_stone = (int) $wpdb->insert_id; +$refused_posts = array(); +$foreign_posts = array(); +for ( $i = 1; $i <= 55; $i++ ) { + $identity = bin2hex( random_bytes( 16 ) ); + $refused_posts[] = $restored_post( 887100000 + $i, $identity ); + ct_test_assert( 1 === $wpdb->insert( ct_scan_tombstones_table(), $stone_row( 887100000 + $i, end( $refused_posts ), $identity ) ), 'Fixture tombstone insert failed: ' . $wpdb->last_error ); + // Same identity as its tombstone, but the post names another job: never a candidate. + $identity = bin2hex( random_bytes( 16 ) ); + $foreign_posts[] = $restored_post( 887100100 + $i, $identity ); + update_post_meta( end( $foreign_posts ), '_ct_scan_job_id', 999999997 ); + ct_test_assert( 1 === $wpdb->insert( ct_scan_tombstones_table(), $stone_row( 887100100 + $i, end( $foreign_posts ), $identity ) ), 'Fixture tombstone insert failed: ' . $wpdb->last_error ); +} +$refuse = static function ( $check, $post ) use ( $refused_posts ) { + return in_array( (int) $post->ID, $refused_posts, true ) ? false : $check; +}; +add_filter( 'pre_delete_post', $refuse, 10, 2 ); +update_option( 'ct_scan_restored_post_cursor', (string) $target_stone, false ); // start just past the older post: it is reached only by wrapping around +ct_scan_apply_tombstones( ct_scan_utc_now() ); +ct_scan_apply_tombstones( ct_scan_utc_now() ); +remove_filter( 'pre_delete_post', $refuse, 10 ); +$refused_left = count( array_filter( $refused_posts, 'get_post' ) ); +$foreign_left = count( array_filter( $foreign_posts, 'get_post' ) ); +ct_test_assert( ! get_post( $target_post ) && 55 === $refused_left && 55 === $foreign_left, "Refused or foreign restored posts starved an older restored post, or a foreign post was deleted ($refused_left refused, $foreign_left foreign of 55 kept)." ); +foreach ( array_merge( $refused_posts, $foreign_posts ) as $id ) { + wp_delete_post( $id, true ); +} +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE job_id BETWEEN 887100000 AND 887100200' ); + foreach ( $request_ids as $id ) { $wpdb->delete( $jobs, array( 'request_id' => $id ), array( '%d' ) ); wp_delete_post( $id, true ); From 5e0e2af58e69e0aab9c4efff63342db6427a2671 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Fri, 23 Oct 2026 16:52:48 -0400 Subject: [PATCH 9/9] Retention: binary identity match, failed queries stop the step, tombstone loop honours deadlines - Compare the tombstone identity and job id with post meta as binary strings, so differing table collations (error 1267) can no longer silently disable the deletion of restored request posts. - Check $wpdb->last_error after every retention/tombstone query: a failed query stops that step (records kept, logged once until a clean run) instead of being read as "no candidates" or "no live record". - The tombstone loop deletes a recognised job row only after the retention deadline that applies today (stored retain_until, never shortened, and the date required under the current configuration) has passed, so a live row whose deletion failed part-way is protected by a period raised since. - Tests: tombstones identity column in another collation; failing candidate and live-lookup queries; failed deletion under a raised period. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/WORDPRESS.md | 14 +++- integrations/wordpress/ct-scan.php | 87 +++++++++++++++++-- integrations/wordpress/tests/retention.php | 97 ++++++++++++++++++++-- 3 files changed, 184 insertions(+), 14 deletions(-) diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 021b1c2..f843d5c 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -618,7 +618,19 @@ queue page): reach a live record). A restored job row that holds a lease (a leased or running scan, or a control check in progress) is deleted again only after that bounded lease ends, so the one-at-a-time gate is never freed while its - traffic may be in flight. Restored rows that cannot be acted on (held or + traffic may be in flight. A recognised job row is also deleted again only once + the retention deadline that applies to it today has passed (the later of its + stored `retain_until` and the date required under the current configuration): + a live row whose retention deletion wrote its tombstone and then failed + part-way cannot be told apart from a restored one, and a period raised since + must protect it. (A row without a derivable deadline cannot be such a live row, + because retention only deletes rows whose dates are all valid; the tombstone + then decides.) The identity comparison between the tombstone and the request's + post meta is binary, so differing table collations (for example + `utf8mb4_unicode_ci` post meta beside a `utf8mb4_unicode_520_ci` tombstones + table) cannot make it fail. A retention query that fails is never read as + "nothing to do": that step stops, the records are kept, and the failure is + logged once until a run completes cleanly. Restored rows that cannot be acted on (held or leased) are skipped in the query, and the candidates are paged from where the previous run stopped (wrapping around), so neither skipped rows nor rows whose deletion keeps failing can hide later restored rows. Restored request posts are diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 2dbc25e..3811f8e 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -1046,6 +1046,49 @@ function ct_scan_deletion_allowed( $row, $now ) { return 'delete' === ct_scan_deletion_decision( $row, $now )[0]; } +/** + * May the tombstone loop delete this job row (one that a tombstone recognises) now, as far as its retention + * deadline is concerned? The loop cannot tell a row restored from a backup from a live row whose retention + * deletion wrote its tombstone and then failed part-way, so it never deletes a row before the deadline that + * retention itself would apply today: the later of the stored retain_until (never shortened) and the date + * required under today's configuration (ct_scan_required_retain_until()). A longer period configured after a + * failed deletion therefore protects that row exactly as it protects any other. + * + * When that date cannot be derived (no valid closure for a current-version acceptance, a damaged creation + * date), the row cannot be such a live row: retention deleted only rows whose dates were all valid, and a + * failed deletion leaves those dates as they were. The row is then a restored copy from before its closure + * (or a damaged one) and the tombstone, which records that this record was already deleted, decides. + */ +function ct_scan_tombstone_deadline_passed( $row, $now ) { + $required = ct_scan_required_retain_until( $row ); + if ( false === $required ) { + return true; + } + $deadline = $required; + if ( isset( $row->retain_until ) && ct_scan_valid_datetime( $row->retain_until ) && $row->retain_until > $deadline ) { + $deadline = $row->retain_until; + } + return $deadline <= $now; +} + +/** + * Did the query just run fail? Retention never mistakes a failed query for "nothing to do" (or, worse, for + * "no live record uses this id"): the caller stops that step and keeps the records. Logged once until a + * retention run completes without a failed query. + */ +function ct_scan_retention_query_failed( $step ) { + global $wpdb; + if ( '' === (string) $wpdb->last_error ) { + return false; + } + $GLOBALS['ct_scan_retention_query_failure'] = true; + if ( ! get_option( 'ct_scan_retention_query_warned' ) ) { + error_log( 'ct-scan: retention step "' . $step . '" stopped: a database query failed; the records are kept and the step is retried on a later run. Logged once until a run completes without a failed query.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + update_option( 'ct_scan_retention_query_warned', $step, false ); + } + return true; +} + /** * A tombstone must not let anyone who reads it (a backup, a database export) test whether a given website * was ever a customer: the scope is stored as a keyed MAC, not as the plain hash of the hostname. @@ -1195,7 +1238,7 @@ function ct_scan_clock_is_sane( $now ) { $limit = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '+5 minutes' )->format( 'Y-m-d H:i:s' ); foreach ( array( 'created_at', 'finished_at', 'closed_at', 'terms_accepted_at' ) as $column ) { $newest = $wpdb->get_var( "SELECT MAX({$column}) FROM {$table} WHERE {$column} < '2200-01-01 00:00:00'" ); - if ( is_string( $newest ) && $newest > $limit ) { + if ( ct_scan_retention_query_failed( 'clock' ) || ( is_string( $newest ) && $newest > $limit ) ) { return false; } } @@ -1247,8 +1290,13 @@ function ct_scan_retention_steps( $now ) { $table = ct_scan_jobs_table(); $terminal = ct_scan_terminal_sql(); $deadline = microtime( true ) + 3.0; + $GLOBALS['ct_scan_retention_query_failure'] = false; // 0. A job that can run again is not closed: forget any closure stamped on it. (Clears dates only: keeps data.) + // If this fails, nothing below may run: a job that can run again could still carry an old deletion date. $wpdb->query( "UPDATE {$table} SET closed_at = NULL, retain_until = NULL WHERE status NOT IN ({$terminal}) AND (closed_at IS NOT NULL OR retain_until IS NOT NULL)" ); + if ( ct_scan_retention_query_failed( 'reopen' ) ) { + return; + } // Everything below stamps "now" or deletes. Refuse all of it if the clock cannot be trusted, so a wrong // clock's "now" is never stamped as a closure, a deletion date or an examination time. $sane = ct_scan_clock_is_sane( $now ); @@ -1258,6 +1306,7 @@ function ct_scan_retention_steps( $now ) { } // 1. Closure is explicit: the first time a job is seen terminal it is stamped. $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET closed_at = COALESCE(finished_at, %s) WHERE closed_at IS NULL AND status IN ({$terminal})", $now ) ); + ct_scan_retention_query_failed( 'closure' ); // only stamps dates; the steps below re-read what they act on // A row judged doubtful is re-examined at most daily, so doubtful rows can never starve the rest. $recheck = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-1 day' )->format( 'Y-m-d H:i:s' ); // 1b. Jobs that predate the opaque identity get one (and so does their request) so a restored request @@ -1267,7 +1316,8 @@ function ct_scan_retention_steps( $now ) { // Paged from a stored cursor (wrapping around) so such rows can never starve later ones. $identity_cursor = max( 0, (int) get_option( 'ct_scan_identity_cursor', 0 ) ); $anonymous = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,request_id,identity FROM {$table} WHERE (identity IS NULL OR identity = '') AND job_id > %d ORDER BY job_id ASC LIMIT 100", $identity_cursor ) ); - foreach ( (array) $anonymous as $row ) { + $anonymous_failed = ct_scan_retention_query_failed( 'identity' ); + foreach ( $anonymous_failed ? array() : (array) $anonymous as $row ) { $identity_cursor = (int) $row->job_id; clean_post_cache( (int) $row->request_id ); $post = get_post( (int) $row->request_id ); @@ -1287,11 +1337,13 @@ function ct_scan_retention_steps( $now ) { $wpdb->update( $table, array( 'identity' => bin2hex( random_bytes( 16 ) ) ), array( 'job_id' => (int) $row->job_id, 'identity' => $row->identity ), array( '%s' ), array( '%d', '%s' ) ); } } - update_option( 'ct_scan_identity_cursor', count( (array) $anonymous ) < 100 ? '0' : (string) $identity_cursor, false ); + if ( ! $anonymous_failed ) { + update_option( 'ct_scan_identity_cursor', count( (array) $anonymous ) < 100 ? '0' : (string) $identity_cursor, false ); + } // 2. The evidence deletion date is set at closure; later it may only be extended (step 4), never shortened. // An untrustworthy base leaves it NULL (kept). $unset = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY job_id ASC LIMIT 200", $recheck ) ); - foreach ( (array) $unset as $row ) { + foreach ( ct_scan_retention_query_failed( 'retain_until' ) ? array() : (array) $unset as $row ) { $when = ct_scan_retain_until( $row ); if ( false !== $when ) { $wpdb->update( $table, array( 'retain_until' => $when ), array( 'job_id' => (int) $row->job_id, 'retain_until' => null ), array( '%s' ), array( '%d', '%s' ) ); @@ -1307,6 +1359,7 @@ function ct_scan_retention_steps( $now ) { $findings_cutoff, $grace_edge ) ); + ct_scan_retention_query_failed( 'findings' ); // a failure only keeps the findings longer // 4. Evidence: delete what has outlived its retain_until (extending a date that is shorter than today's // rules require), except under a hold. One budget of deletion attempts for the whole run (steps 4 and 5). $deletions_left = ct_scan_retention_batch_limit(); @@ -1316,7 +1369,7 @@ function ct_scan_retention_steps( $now ) { $grace_edge, $recheck ) ); - foreach ( (array) $due as $row ) { + foreach ( ct_scan_retention_query_failed( 'evidence' ) ? array() : (array) $due as $row ) { if ( $deletions_left < 1 || microtime( true ) > $deadline ) { break; // enough for one run: continue next time } @@ -1335,6 +1388,10 @@ function ct_scan_retention_steps( $now ) { // 5. A restored backup must not resurrect deleted records: delete matches again (same budget). ct_scan_apply_tombstones( $now, $deadline, $deletions_left ); $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE deleted_at < %s', ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . max( 90, (int) ct_scan_config()['tombstone_retention_days'] ) . ' days' )->format( 'Y-m-d H:i:s' ) ) ); + ct_scan_retention_query_failed( 'tombstone_expiry' ); // a failure only keeps tombstones longer + if ( empty( $GLOBALS['ct_scan_retention_query_failure'] ) && get_option( 'ct_scan_retention_query_warned' ) ) { + delete_option( 'ct_scan_retention_query_warned' ); + } } /** @@ -1380,6 +1437,9 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu $upper, $grace_edge ) ); + if ( ct_scan_retention_query_failed( 'tombstone_rows' ) ) { + break; // never "no candidates": stop here and continue from the stored cursor next run + } if ( empty( $ghosts ) ) { if ( $wrapped ) { $finished = true; @@ -1409,7 +1469,10 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu // A restored row that holds a live lease (a scan, or a control check in progress) is left until the // lease ends (bounded; maintenance reclaims it): deleting it would free the one-at-a-time gate while // target traffic for it may still be in flight. Re-checked here under the lock. - if ( $row && hash_equals( (string) $ghost->scope_mac, ct_scan_tombstone_mac( $row->scope_hash ) ) && ! ct_scan_job_on_hold( $row, $now ) && ! ct_scan_job_holds_lease( $row ) ) { + // The row is also kept until the retention deadline that applies to it today has passed: a live row + // whose retention deletion wrote this tombstone and then failed part-way looks exactly like a restored + // one, and a period raised since must protect it (ct_scan_tombstone_deadline_passed()). + if ( $row && hash_equals( (string) $ghost->scope_mac, ct_scan_tombstone_mac( $row->scope_hash ) ) && ! ct_scan_job_on_hold( $row, $now ) && ! ct_scan_job_holds_lease( $row ) && ct_scan_tombstone_deadline_passed( $row, $now ) ) { --$deletions_left; // A failure leaves the row (and is retried next run); it never stops the other candidates. ct_scan_delete_job_records( $row, 'restored', (string) $ghost->identity ); @@ -1435,14 +1498,17 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu $restored = $wpdb->get_results( $wpdb->prepare( 'SELECT t.tombstone_id, t.job_id, t.request_id, t.identity FROM ' . $stones . ' t' . ' INNER JOIN ' . $wpdb->posts . " p ON p.ID = t.request_id AND p.post_type = 'ct_promo_request'" - . ' INNER JOIN ' . $postmeta . " m ON m.post_id = t.request_id AND m.meta_key = '_ct_scan_identity' AND m.meta_value = t.identity" - . ' INNER JOIN ' . $postmeta . " k ON k.post_id = t.request_id AND k.meta_key = '_ct_scan_job_id' AND k.meta_value = t.job_id" + . ' INNER JOIN ' . $postmeta . " m ON m.post_id = t.request_id AND m.meta_key = '_ct_scan_identity' AND CAST(m.meta_value AS BINARY) = CAST(t.identity AS BINARY)" + . ' INNER JOIN ' . $postmeta . " k ON k.post_id = t.request_id AND k.meta_key = '_ct_scan_job_id' AND CAST(k.meta_value AS BINARY) = CAST(t.job_id AS BINARY)" . " WHERE t.identity IS NOT NULL AND t.identity <> '' AND t.tombstone_id > %d AND t.tombstone_id <= %d" . ' AND NOT EXISTS (SELECT 1 FROM ' . $table . ' j WHERE j.request_id = t.request_id OR j.job_id = t.job_id)' . ' GROUP BY t.tombstone_id, t.job_id, t.request_id, t.identity ORDER BY t.tombstone_id ASC LIMIT 50', $after_stone, $upper_stone ) ); + if ( ct_scan_retention_query_failed( 'tombstone_posts' ) ) { + break; // never "no candidates": stop here and continue from the stored cursor next run + } if ( empty( $restored ) ) { if ( $post_wrapped ) { $post_done = true; @@ -1463,6 +1529,11 @@ function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = nu $after_stone = (int) $stone->tombstone_id; try { $live = $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$table} WHERE request_id = %d OR job_id = %d LIMIT 1", (int) $stone->request_id, (int) $stone->job_id ) ); + if ( ct_scan_retention_query_failed( 'tombstone_posts' ) ) { + // A failed lookup is not "no live record": keep the post and stop; the next run retries from here. + update_option( 'ct_scan_restored_post_cursor', (string) max( 0, $after_stone - 1 ), false ); + return; + } clean_post_cache( (int) $stone->request_id ); if ( ! $live && hash_equals( (string) $stone->identity, ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_identity' ) ) && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { --$deletions_left; diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php index 1b44bc8..64443e8 100644 --- a/integrations/wordpress/tests/retention.php +++ b/integrations/wordpress/tests/retention.php @@ -106,7 +106,7 @@ function ct_test_exists( $job ) { ct_test_assert( array() === array_intersect( $columns, array( 'contact_email', 'contact_name', 'target_origin', 'result_json', 'terms_hash', 'scope_hash' ) ), 'The tombstone table can hold personal or customer data.' ); // A restored backup brings the rows back: maintenance deletes them again. -$ghost = array( 'job_id' => $edge_del['job_id'], 'request_id' => $edge_del['request_id'], 'target_origin' => 'https://ret-del.example.net/', 'scope_hash' => $plain, 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored Person', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $stone->created_at, 'finished_at' => ct_test_old( '-1 day' ), 'closed_at' => ct_test_old( '-1 day' ) ); +$ghost = array( 'job_id' => $edge_del['job_id'], 'request_id' => $edge_del['request_id'], 'target_origin' => 'https://ret-del.example.net/', 'scope_hash' => $plain, 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-3 years -1 day' ), 'retention_checked_at' => ct_scan_utc_now(), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored Person', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $stone->created_at, 'finished_at' => ct_test_old( '-1 day' ), 'closed_at' => ct_test_old( '-1 day' ) ); $wpdb->insert( $jobs, $ghost ); $restored_post = wp_insert_post( array( 'import_id' => $edge_del['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); update_post_meta( $edge_del['request_id'], '_ct_scan_job_id', $edge_del['job_id'] ); @@ -478,7 +478,7 @@ function ct_test_exists( $job ) { } // Restored rows that cannot be acted on (held, leased) never starve a later restored row in the tombstone loop. -$restored_base = array( 'target_origin' => 'https://ret-starve.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-starve.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => ct_test_old( '-1 hour' ) ); +$restored_base = array( 'target_origin' => 'https://ret-starve.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-starve.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-4 years' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => ct_test_old( '-4 years' ), 'retention_checked_at' => ct_scan_utc_now() ); for ( $i = 0; $i < 56; $i++ ) { $extra = 9 === $i % 10 ? array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+1 hour' ) ) : array( 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ); ct_test_assert( 1 === $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 887000100 + $i, 'request_id' => 886000100 + $i ), $extra ) ), 'Fixture insert of a restored row failed: ' . $wpdb->last_error ); @@ -693,7 +693,7 @@ function ct_test_make_due( $job ) { $saved = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE job_id = %d", $recycled['job_id'] ), ARRAY_A ); ct_test_maintain(); ct_test_assert( ! ct_test_exists( $recycled ) && ! get_post( $recycled['request_id'] ), 'Fixture expected the recycled-id job to be deleted.' ); -$saved['retain_until'] = ct_test_old( '+1 year' ); // only the tombstone can recognise it +$saved['retention_checked_at'] = ct_scan_utc_now(); // examined just now: only the tombstone loop reaches it $wpdb->insert( $jobs, $saved ); wp_insert_post( array( 'import_id' => $recycled['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'another record' ) ); update_post_meta( $recycled['request_id'], '_ct_scan_identity', str_repeat( 'e', 32 ) ); @@ -715,7 +715,7 @@ function ct_test_make_due( $job ) { ct_test_maintain(); ct_test_assert( 0 === count( array_filter( $ghost_jobs, 'ct_test_exists' ) ), 'Fixture expected the budget ghosts to be deleted.' ); foreach ( $ghost_rows as $g ) { - $g['retain_until'] = ct_test_old( '+1 year' ); // only the tombstone loop can reach these + $g['retention_checked_at'] = ct_scan_utc_now(); // examined just now: only the tombstone loop reaches these $wpdb->insert( $jobs, $g ); } $budget_jobs = array(); @@ -765,7 +765,7 @@ function ct_test_make_due( $job ) { ct_test_maintain(); ct_test_assert( ! ct_test_exists( $gone ), 'Fixture expected the job to be deleted first.' ); $gstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $gone['job_id'] ) ); - $wpdb->insert( $jobs, array_merge( array( 'job_id' => $gone['job_id'], 'request_id' => $gone['request_id'], 'target_origin' => "https://ret-restore-{$kind}.example.net/", 'scope_hash' => hash( 'sha256', "https://ret-restore-{$kind}.example.net/" ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'created_at' => $gstone->created_at ), $leased ) ); + $wpdb->insert( $jobs, array_merge( array( 'job_id' => $gone['job_id'], 'request_id' => $gone['request_id'], 'target_origin' => "https://ret-restore-{$kind}.example.net/", 'scope_hash' => hash( 'sha256', "https://ret-restore-{$kind}.example.net/" ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-3 years -1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'created_at' => $gstone->created_at ), $leased ) ); ct_test_maintain(); ct_test_assert( ct_test_exists( $gone ), "A restored row holding a live {$kind} lease was deleted by its tombstone mid-lease." ); $wpdb->update( $jobs, 'control' === $kind ? array( 'control_lease_until' => ct_test_old( '-1 minute' ) ) : array( 'lease_until' => ct_test_old( '-1 minute' ) ), array( 'job_id' => $gone['job_id'] ) ); @@ -849,6 +849,93 @@ function ct_test_make_due( $job ) { } $wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE job_id BETWEEN 887100000 AND 887100200' ); +// ---- Sixth review: collations, failed queries, a failed deletion under a raised period --------------------------- +// A tombstones table whose collation differs from the post meta's (utf8mb4_unicode_ci post meta beside a +// utf8mb4_unicode_520_ci tombstones table, say) must not make the restored-post comparison fail silently. +$stones_table = ct_scan_tombstones_table(); +$column_info = static function ( $table, $column ) use ( $wpdb ) { + return $wpdb->get_row( $wpdb->prepare( 'SELECT CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s', $table, $column ) ); +}; +$meta_column = $column_info( $wpdb->postmeta, 'meta_value' ); +$identity_column = $column_info( $stones_table, 'identity' ); +ct_test_assert( $meta_column && $identity_column && $meta_column->charset && $identity_column->collation, 'Fixture could not read the column collations.' ); +$other_collation = null; +foreach ( array( '_general_ci', '_unicode_ci', '_unicode_520_ci' ) as $suffix ) { + $candidate = $meta_column->charset . $suffix; + if ( $candidate !== $meta_column->collation && $wpdb->get_var( $wpdb->prepare( 'SELECT COLLATION_NAME FROM information_schema.COLLATIONS WHERE COLLATION_NAME = %s', $candidate ) ) ) { + $other_collation = $candidate; + break; + } +} +ct_test_assert( null !== $other_collation, 'Fixture found no second collation for ' . $meta_column->charset . '.' ); +$collated = ct_test_aged_job( 'ret-collation.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $collated ) && ! get_post( $collated['request_id'] ), 'Fixture expected the collation job to be deleted.' ); +$collated_stone = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$stones_table} WHERE job_id = %d", $collated['job_id'] ) ); +try { + ct_test_assert( false !== $wpdb->query( "ALTER TABLE {$stones_table} MODIFY identity char(32) CHARACTER SET {$meta_column->charset} COLLATE {$other_collation} NULL" ), 'Fixture could not change the identity collation: ' . $wpdb->last_error ); + // The fixture really reproduces the problem: a plain comparison across the two columns is refused. + $suppressed = $wpdb->suppress_errors( true ); + $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} m INNER JOIN {$stones_table} t ON m.meta_value = t.identity" ); + $plain_error = $wpdb->last_error; + $wpdb->suppress_errors( $suppressed ); + ct_test_assert( '' !== $plain_error, "Fixture expected {$meta_column->collation} against {$other_collation} to be an illegal mix of collations." ); + wp_insert_post( array( 'import_id' => $collated['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); + update_post_meta( $collated['request_id'], '_ct_scan_job_id', $collated['job_id'] ); + update_post_meta( $collated['request_id'], '_ct_scan_identity', $collated_stone->identity ); + delete_option( 'ct_scan_retention_query_warned' ); + ct_test_maintain(); + ct_test_assert( ! get_post( $collated['request_id'] ) && empty( $GLOBALS['ct_scan_retention_query_failure'] ) && ! get_option( 'ct_scan_retention_query_warned' ), "A restored request was not deleted again when the tombstones table uses another collation ({$other_collation}) than the post meta ({$meta_column->collation})." ); +} finally { + $wpdb->query( "ALTER TABLE {$stones_table} MODIFY identity char(32) CHARACTER SET {$identity_column->charset} COLLATE {$identity_column->collation} NULL" ); +} +ct_test_assert( $identity_column->collation === $column_info( $stones_table, 'identity' )->collation, 'Fixture could not restore the identity collation.' ); + +// A failed retention query is never read as "nothing to do" or "no live record": the records are kept, the failure +// is logged once, and the next clean run does the work and forgets the warning. +$failing = ct_test_aged_job( 'ret-failing-query.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $failing ), 'Fixture expected the failing-query job to be deleted.' ); +$failing_stone = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$stones_table} WHERE job_id = %d", $failing['job_id'] ) ); +wp_insert_post( array( 'import_id' => $failing['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); +update_post_meta( $failing['request_id'], '_ct_scan_job_id', $failing['job_id'] ); +update_post_meta( $failing['request_id'], '_ct_scan_identity', $failing_stone->identity ); +foreach ( array( 'candidates' => "'_ct_scan_identity'", 'live lookup' => ' OR job_id = ' ) as $kind => $marker ) { + $break_query = static function ( $query ) use ( $marker, $jobs ) { + $is_target = ' OR job_id = ' === $marker ? 0 === strpos( $query, "SELECT job_id FROM {$jobs} WHERE request_id = " ) : ( false !== strpos( $query, 'INNER JOIN' ) && false !== strpos( $query, $marker ) ); + return $is_target ? $query . ' AND (' : $query; // a syntax error + }; + delete_option( 'ct_scan_retention_query_warned' ); + add_filter( 'query', $break_query ); + $suppressed = $wpdb->suppress_errors( true ); + ct_test_maintain(); + $wpdb->suppress_errors( $suppressed ); + remove_filter( 'query', $break_query ); + ct_test_assert( get_post( $failing['request_id'] ) && 'tombstone_posts' === get_option( 'ct_scan_retention_query_warned' ), "A failed restored-post query ({$kind}) was treated as no candidates or no live record, or was not logged." ); +} +ct_test_maintain(); +ct_test_assert( ! get_post( $failing['request_id'] ) && ! get_option( 'ct_scan_retention_query_warned' ), 'After the query recovered, the restored request was not deleted or the warning was not cleared.' ); + +// A live row whose retention deletion wrote its tombstone and then failed part-way is not deleted by that tombstone +// before the deadline of a retention period raised since (holds aside, the tombstone loop applies the same deadline). +$partial = ct_test_aged_job( 'ret-partial.example.net', 'cancelled', '-6 years -3 days', '-6 years -2 days', '2027-06-01-r3' ); +ct_test_make_due( $partial ); +$block_partial = static function ( $check, $post ) use ( $partial ) { + return (int) $partial['request_id'] === (int) $post->ID ? false : $check; +}; +add_filter( 'pre_delete_post', $block_partial, 10, 2 ); +ct_test_maintain(); +remove_filter( 'pre_delete_post', $block_partial, 10 ); +$pr = ct_scan_row( $partial['job_id'] ); +ct_test_assert( $pr && get_post( $partial['request_id'] ) && null !== $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$stones_table} WHERE job_id = %d", $partial['job_id'] ) ), 'Fixture expected a deletion that wrote its tombstone and then failed.' ); +// Examined just now, so the evidence step leaves it alone: only the tombstone loop decides in the next run. +$wpdb->update( $jobs, array( 'retention_checked_at' => ct_scan_utc_now() ), array( 'job_id' => $partial['job_id'] ) ); +$run_raised(); // eight years from closure: not due for almost two years +ct_test_assert( ct_test_exists( $partial ) && get_post( $partial['request_id'] ) && $pr->retain_until === ct_scan_row( $partial['job_id'] )->retain_until, 'A tombstone deleted a live row (whose deletion had failed) before the deadline of a period raised since.' ); +// Under the period in force when it was due, the tombstone loop completes the deletion. +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $partial ) && ! get_post( $partial['request_id'] ), 'The tombstone loop did not complete a failed deletion whose deadline has passed.' ); + foreach ( $request_ids as $id ) { $wpdb->delete( $jobs, array( 'request_id' => $id ), array( '%d' ) ); wp_delete_post( $id, true );