PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / diagnostics / CanaryReceiptEvidence.php

CanaryReceiptEvidence.php in 404 Solution trunk, at includes/diagnostics/CanaryReceiptEvidence.php

385 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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