| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Reconstructs the adaptive-canary interpretation from durable browser receipts. |
| 9 |
* |
| 10 |
* Beta.3 posted the complete nested observation graph to a bounded server field. |
| 11 |
* That final request could be truncated into invalid JSON, but every measured |
| 12 |
* step had already relayed a normalized receipt through the checkpoint journal. |
| 13 |
* This class recovers the matrix from those receipts without trusting missing |
| 14 |
* evidence as a failed probe. |
| 15 |
* |
| 16 |
* A receipt names its own browser session -- as `session_key`, the md5 this |
| 17 |
* journal already files `detach_ab_mode` under -- so the checkpoint journal |
| 18 |
* answers the scoping question on its own. It did not always: the session used to live |
| 19 |
* only in the stage-trace journal, and this class resolved it across the two |
| 20 |
* channels. That join fails whenever the trace channel is silent for ANY |
| 21 |
* reason, and on the 2026-08-27 Azure App Service capture it was silent because |
| 22 |
* its writer had never been armed -- so a complete fifteen-receipt run in the |
| 23 |
* checkpoint journal reconstructed as `no_receipts`, with no verdict in the |
| 24 |
* payload and nothing in the report to say why. |
| 25 |
* |
| 26 |
* The trace journal is still read, for two things it alone owns: the plugin |
| 27 |
* version that produced the run, and the carrier-request join that scopes |
| 28 |
* receipts written by a build older than the session stamp. A record that |
| 29 |
* carries a session is scoped by it and never by the carrier, so a stamped |
| 30 |
* foreign run cannot be adopted by a carrier that happens to be in range. |
| 31 |
* |
| 32 |
* The last static-asset receipt starts the latest ladder run, preventing two |
| 33 |
* runs in one tab from being folded into one matrix. |
| 34 |
*/ |
| 35 |
final class ABJ_404_Solution_CanaryReceiptEvidence { |
| 36 |
|
| 37 |
const STATUS_RECONSTRUCTED = 'reconstructed'; |
| 38 |
const STATUS_INCOMPLETE = 'incomplete'; |
| 39 |
const STATUS_NO_SESSION = 'no_session'; |
| 40 |
const STATUS_NO_RECEIPTS = 'no_receipts'; |
| 41 |
const STATUS_ERROR = 'error'; |
| 42 |
|
| 43 |
/** Beta.3 interleaves one fixed-size control after each measured step. */ |
| 44 |
const REQUIRED_BASELINE_RECEIPTS = 8; |
| 45 |
|
| 46 |
/** Evidence required before absence can never be mistaken for failure. */ |
| 47 |
const REQUIRED_STEPS = array( |
| 48 |
ABJ_404_Solution_CanaryLadderStep::STATIC_ASSET, |
| 49 |
ABJ_404_Solution_CanaryLadderStep::AUTH_ONLY, |
| 50 |
ABJ_404_Solution_CanaryLadderStep::POST_LIMITER, |
| 51 |
ABJ_404_Solution_CanaryLadderStep::SUMMARY, |
| 52 |
ABJ_404_Solution_CanaryLadderStep::INERT, |
| 53 |
ABJ_404_Solution_CanaryLadderStep::COMPRESS_ON, |
| 54 |
ABJ_404_Solution_CanaryLadderStep::COMPRESS_OFF, |
| 55 |
ABJ_404_Solution_CanaryLadderStep::STREAM, |
| 56 |
); |
| 57 |
|
| 58 |
/** |
| 59 |
* Reconstruct the latest complete canary run for one browser session. |
| 60 |
* |
| 61 |
* @return array<string, mixed> |
| 62 |
*/ |
| 63 |
public static function forSession(string $sessionId): array { |
| 64 |
$sessionId = substr($sessionId, 0, 64); |
| 65 |
$sessionKey = ABJ_404_Solution_DetachAbExperiment::sessionKey($sessionId); |
| 66 |
$record = self::emptyRecord($sessionId); |
| 67 |
if ($sessionId === '') { |
| 68 |
$record['status'] = self::STATUS_NO_SESSION; |
| 69 |
return $record; |
| 70 |
} |
| 71 |
try { |
| 72 |
$traceSource = ABJ_404_Solution_AjaxTraceJournal::supportCollectionSource(); |
| 73 |
$checkpointSource = ABJ_404_Solution_CheckpointJournalReader::supportCollectionSource(); |
| 74 |
$traceLines = ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines( |
| 75 |
$traceSource['paths'] |
| 76 |
); |
| 77 |
$checkpointLines = ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines( |
| 78 |
$checkpointSource['paths'] |
| 79 |
); |
| 80 |
$session = self::sessionRequests($traceLines, $sessionId); |
| 81 |
$record['plugin_version'] = $session['plugin_version']; |
| 82 |
$record['journal_lines_scanned'] = count($traceLines) + count($checkpointLines); |
| 83 |
return self::reconstruct( |
| 84 |
$record, $checkpointLines, $session['request_ids'], $sessionKey, $sessionId); |
| 85 |
} catch (Throwable $e) { |
| 86 |
$record['status'] = self::STATUS_ERROR; |
| 87 |
$record['error'] = substr($e->getMessage(), 0, 200); |
| 88 |
return $record; |
| 89 |
} |
| 90 |
} |
| 91 |
|
| 92 |
/** @return array<string, mixed> */ |
| 93 |
private static function emptyRecord(string $sessionId): array { |
| 94 |
return array( |
| 95 |
'status' => self::STATUS_NO_RECEIPTS, |
| 96 |
'source' => 'checkpoint_receipts', |
| 97 |
'session_key' => ABJ_404_Solution_DetachAbExperiment::sessionKey($sessionId), |
| 98 |
'plugin_version' => '', |
| 99 |
'receipt_records' => 0, |
| 100 |
'baseline_receipts' => 0, |
| 101 |
'malformed_receipts' => 0, |
| 102 |
'missing_required_evidence' => array(), |
| 103 |
'journal_lines_scanned' => 0, |
| 104 |
'body_delivery' => null, |
| 105 |
'interpretation' => null, |
| 106 |
); |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* @param array<int, string> $lines |
| 111 |
* @return array{request_ids: array<string, bool>, plugin_version: string} |
| 112 |
*/ |
| 113 |
private static function sessionRequests(array $lines, string $sessionId): array { |
| 114 |
$requestIds = array(); |
| 115 |
$pluginVersion = ''; |
| 116 |
foreach ($lines as $line) { |
| 117 |
$decoded = json_decode($line, true); |
| 118 |
if (!is_array($decoded) |
| 119 |
|| self::scalarField($decoded, 'session_id') !== $sessionId) { |
| 120 |
continue; |
| 121 |
} |
| 122 |
$requestId = self::ledgerId($decoded['request_id'] ?? null); |
| 123 |
if ($requestId !== '') { |
| 124 |
$requestIds[$requestId] = true; |
| 125 |
} |
| 126 |
$candidateVersion = self::scalarField($decoded, 'plugin_version'); |
| 127 |
if ($candidateVersion !== '') { |
| 128 |
$pluginVersion = substr($candidateVersion, 0, 64); |
| 129 |
} |
| 130 |
} |
| 131 |
return array('request_ids' => $requestIds, 'plugin_version' => $pluginVersion); |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* @param array<string, mixed> $record |
| 136 |
* @param array<int, string> $lines |
| 137 |
* @param array<string, bool> $sessionRequestIds |
| 138 |
* @return array<string, mixed> |
| 139 |
*/ |
| 140 |
private static function reconstruct( |
| 141 |
array $record, |
| 142 |
array $lines, |
| 143 |
array $sessionRequestIds, |
| 144 |
string $sessionKey, |
| 145 |
string $sessionId |
| 146 |
): array { |
| 147 |
$receipts = self::sessionReceiptRecords($lines, $sessionRequestIds, $sessionKey); |
| 148 |
if ($receipts === array()) { |
| 149 |
return $record; |
| 150 |
} |
| 151 |
$latestRun = self::latestRun($receipts); |
| 152 |
$steps = array(); |
| 153 |
$controls = array(); |
| 154 |
foreach ($latestRun as $receipt) { |
| 155 |
if (($receipt['event'] ?? '') === 'canary_step_client_receipt') { |
| 156 |
$steps[] = $receipt; |
| 157 |
} elseif (($receipt['event'] ?? '') === 'concurrent_control_client_receipt') { |
| 158 |
$controls[] = $receipt; |
| 159 |
} |
| 160 |
} |
| 161 |
$projection = self::projectSteps($steps); |
| 162 |
$concurrent = self::latestConcurrentControl($controls); |
| 163 |
$missing = self::missingEvidence($projection, $concurrent); |
| 164 |
|
| 165 |
$record['receipt_records'] = count($latestRun); |
| 166 |
$record['baseline_receipts'] = count($projection['baselines']); |
| 167 |
$record['malformed_receipts'] = $projection['malformed']; |
| 168 |
$record['missing_required_evidence'] = $missing; |
| 169 |
if ($missing !== array() || $concurrent === null) { |
| 170 |
$record['status'] = self::STATUS_INCOMPLETE; |
| 171 |
return $record; |
| 172 |
} |
| 173 |
|
| 174 |
$observations = $projection['observations']; |
| 175 |
$observations[ABJ_404_Solution_CanaryLadderStep::CONCURRENT_CONTROL] = |
| 176 |
self::projectConcurrentControl($concurrent); |
| 177 |
// The emitted-against-delivered join is rebuilt from the SAME |
| 178 |
// receipts this reconstruction already holds. Recomputing the matrix |
| 179 |
// without it would drop bodyRewrittenInTransitCausal from exactly the |
| 180 |
// payload a maintainer reads when the live interpret response never |
| 181 |
// arrived -- the case this whole path exists for. |
| 182 |
$bodyDelivery = ABJ_404_Solution_ResponseBodyDeliveryEvidence::fromLines( |
| 183 |
$lines, |
| 184 |
$sessionKey, |
| 185 |
ABJ_404_Solution_EncodedTableResponseSize::forSession($sessionId) |
| 186 |
); |
| 187 |
$record['status'] = self::STATUS_RECONSTRUCTED; |
| 188 |
$record['body_delivery'] = $bodyDelivery; |
| 189 |
$record['interpretation'] = |
| 190 |
ABJ_404_Solution_CanaryLadderInterpretation::interpret($observations, true, $bodyDelivery); |
| 191 |
return $record; |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Every receipt belonging to this session, in journal order. |
| 196 |
* |
| 197 |
* A record that names a session is scoped BY that name and by nothing else: |
| 198 |
* it is kept when the name matches and dropped when it does not, so a |
| 199 |
* foreign run can never be adopted through a carrier that happens to be in |
| 200 |
* the trace-derived set. Only a record with no session of its own -- one |
| 201 |
* written before the stamp existed -- falls back to the carrier join, and |
| 202 |
* on a host whose trace journal is silent that set is empty, which drops it. |
| 203 |
* Unattributable evidence is dropped, never adopted. |
| 204 |
* |
| 205 |
* @param array<int, string> $lines |
| 206 |
* @param array<string, bool> $sessionRequestIds |
| 207 |
* @param string $sessionKey DetachAbExperiment::sessionKey() of the requested session. |
| 208 |
* @return array<int, array<string, mixed>> |
| 209 |
*/ |
| 210 |
private static function sessionReceiptRecords( |
| 211 |
array $lines, |
| 212 |
array $sessionRequestIds, |
| 213 |
string $sessionKey |
| 214 |
): array { |
| 215 |
$receipts = array(); |
| 216 |
foreach ($lines as $line) { |
| 217 |
if (strpos($line, 'canary_step_client_receipt') === false |
| 218 |
&& strpos($line, 'concurrent_control_client_receipt') === false) { |
| 219 |
continue; |
| 220 |
} |
| 221 |
$decoded = json_decode($line, true); |
| 222 |
if (!is_array($decoded)) { |
| 223 |
continue; |
| 224 |
} |
| 225 |
if (($decoded['event'] ?? '') !== 'canary_step_client_receipt' |
| 226 |
&& ($decoded['event'] ?? '') !== 'concurrent_control_client_receipt') { |
| 227 |
continue; |
| 228 |
} |
| 229 |
$recordSessionKey = self::scalarField($decoded, 'session_key'); |
| 230 |
$belongs = $recordSessionKey !== '' |
| 231 |
? $recordSessionKey === $sessionKey |
| 232 |
: isset($sessionRequestIds[self::ledgerId($decoded['carried_by'] ?? null)]); |
| 233 |
if ($belongs) { |
| 234 |
$receipts[] = $decoded; |
| 235 |
} |
| 236 |
} |
| 237 |
return $receipts; |
| 238 |
} |
| 239 |
|
| 240 |
/** |
| 241 |
* @param array<int, array<string, mixed>> $receipts |
| 242 |
* @return array<int, array<string, mixed>> |
| 243 |
*/ |
| 244 |
private static function latestRun(array $receipts): array { |
| 245 |
$start = -1; |
| 246 |
foreach ($receipts as $index => $receipt) { |
| 247 |
$step = self::reportedStep($receipt); |
| 248 |
if ($step === ABJ_404_Solution_CanaryLadderStep::STATIC_ASSET) { |
| 249 |
$start = $index; |
| 250 |
} |
| 251 |
} |
| 252 |
return $start < 0 ? $receipts : array_slice($receipts, $start); |
| 253 |
} |
| 254 |
|
| 255 |
/** |
| 256 |
* @param array<int, array<string, mixed>> $receipts |
| 257 |
* @return array{observations: array<string, mixed>, baselines: array<int, array<string, mixed>>, malformed: int} |
| 258 |
*/ |
| 259 |
private static function projectSteps(array $receipts): array { |
| 260 |
$byStep = array(); |
| 261 |
$baselines = array(); |
| 262 |
$malformed = 0; |
| 263 |
$seen = array(); |
| 264 |
foreach ($receipts as $receipt) { |
| 265 |
$step = self::reportedStep($receipt); |
| 266 |
$stepRequestId = self::ledgerId($receipt['step_request_id'] ?? null); |
| 267 |
$valid = ($receipt['envelope'] ?? '') === 'full' |
| 268 |
&& ($receipt['decoded'] ?? null) === true |
| 269 |
&& $step !== '' |
| 270 |
&& ($step === ABJ_404_Solution_CanaryLadderStep::STATIC_ASSET |
| 271 |
|| $stepRequestId !== '') |
| 272 |
&& empty($receipt['truncated_on_arrival']); |
| 273 |
if (!$valid) { |
| 274 |
$malformed++; |
| 275 |
if ($step !== '') { |
| 276 |
$byStep[$step] = null; |
| 277 |
} |
| 278 |
continue; |
| 279 |
} |
| 280 |
$identity = $step === ABJ_404_Solution_CanaryLadderStep::STATIC_ASSET |
| 281 |
? $step : $step . '|' . $stepRequestId; |
| 282 |
if (isset($seen[$identity])) { |
| 283 |
continue; |
| 284 |
} |
| 285 |
$seen[$identity] = true; |
| 286 |
$projected = array( |
| 287 |
'ok' => ($receipt['ok'] ?? null) === true, |
| 288 |
'ms' => is_numeric($receipt['ms'] ?? null) ? (int)$receipt['ms'] : -1, |
| 289 |
); |
| 290 |
if ($step === ABJ_404_Solution_CanaryLadderStep::BASELINE_CONTROL) { |
| 291 |
$baselines[] = $projected; |
| 292 |
} else { |
| 293 |
$byStep[$step] = $projected; |
| 294 |
} |
| 295 |
} |
| 296 |
$observations = $byStep; |
| 297 |
$observations[ABJ_404_Solution_CanaryLadderStep::BASELINE_CONTROL] = $baselines; |
| 298 |
return array( |
| 299 |
'observations' => $observations, |
| 300 |
'baselines' => $baselines, |
| 301 |
'malformed' => $malformed, |
| 302 |
); |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* @param array<int, array<string, mixed>> $controls |
| 307 |
* @return array<string, mixed>|null |
| 308 |
*/ |
| 309 |
private static function latestConcurrentControl(array $controls): ?array { |
| 310 |
$latest = null; |
| 311 |
foreach ($controls as $control) { |
| 312 |
$latest = $control; |
| 313 |
} |
| 314 |
return $latest; |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* @param array{observations: array<string, mixed>, baselines: array<int, array<string, mixed>>, malformed: int} $projection |
| 319 |
* @param array<string, mixed>|null $concurrent |
| 320 |
* @return array<int, string> |
| 321 |
*/ |
| 322 |
private static function missingEvidence(array $projection, ?array $concurrent): array { |
| 323 |
$missing = array(); |
| 324 |
foreach (self::REQUIRED_STEPS as $step) { |
| 325 |
if (!is_array($projection['observations'][$step] ?? null)) { |
| 326 |
$missing[] = $step; |
| 327 |
} |
| 328 |
} |
| 329 |
if (count($projection['baselines']) < self::REQUIRED_BASELINE_RECEIPTS) { |
| 330 |
$missing[] = ABJ_404_Solution_CanaryLadderStep::BASELINE_CONTROL; |
| 331 |
} |
| 332 |
if ($concurrent === null |
| 333 |
|| !ABJ_404_Solution_ConcurrentControlReceipt::isCompleteJournalRecord( |
| 334 |
$concurrent |
| 335 |
)) { |
| 336 |
$missing[] = ABJ_404_Solution_CanaryLadderStep::CONCURRENT_CONTROL; |
| 337 |
} |
| 338 |
if ($projection['malformed'] > 0 && $missing === array()) { |
| 339 |
$missing[] = 'malformed_receipt'; |
| 340 |
} |
| 341 |
return $missing; |
| 342 |
} |
| 343 |
|
| 344 |
/** |
| 345 |
* @param array<string, mixed> $record |
| 346 |
* @return array{ |
| 347 |
* tableOutcome: string, |
| 348 |
* receipt: array{ok: bool}, |
| 349 |
* overlap: array{state: string, durationMs: int|null} |
| 350 |
* } |
| 351 |
*/ |
| 352 |
private static function projectConcurrentControl(array $record): array { |
| 353 |
$report = is_array($record['report'] ?? null) ? $record['report'] : array(); |
| 354 |
$receipt = is_array($report['receipt'] ?? null) ? $report['receipt'] : array(); |
| 355 |
$overlap = is_array($report['overlap'] ?? null) ? $report['overlap'] : array(); |
| 356 |
return array( |
| 357 |
'tableOutcome' => self::scalarField($report, 'tableOutcome'), |
| 358 |
'receipt' => array('ok' => ($receipt['ok'] ?? null) === true), |
| 359 |
'overlap' => array( |
| 360 |
'state' => self::scalarField($overlap, 'state'), |
| 361 |
'durationMs' => is_numeric($overlap['durationMs'] ?? null) |
| 362 |
? (int)$overlap['durationMs'] : null, |
| 363 |
), |
| 364 |
); |
| 365 |
} |
| 366 |
|
| 367 |
/** @param array<string, mixed> $record */ |
| 368 |
private static function reportedStep(array $record): string { |
| 369 |
$step = self::scalarField($record, 'step'); |
| 370 |
return $step !== '' ? $step : self::scalarField($record, 'reported_step'); |
| 371 |
} |
| 372 |
|
| 373 |
/** @param mixed $value */ |
| 374 |
private static function ledgerId($value): string { |
| 375 |
$candidate = is_scalar($value) ? (string)$value : ''; |
| 376 |
return preg_match('/^[A-Za-z0-9]{8,64}$/', $candidate) === 1 ? $candidate : ''; |
| 377 |
} |
| 378 |
|
| 379 |
/** @param array<array-key, mixed> $record */ |
| 380 |
private static function scalarField(array $record, string $field): string { |
| 381 |
$value = $record[$field] ?? null; |
| 382 |
return is_scalar($value) ? (string)$value : ''; |
| 383 |
} |
| 384 |
} |
| 385 |
|