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 / DetachAbExperiment.php

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

177 lines 7.6 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 * Assigns the bounded detach A/B diagnostic for one request workload.
9 *
10 * A pre-release build and a non-empty browser session are both required. Each
11 * workload scope receives an independent six-slot counter, arranged as
12 * alternating AB/BA pairs so workload or time drift cannot manufacture a detach
13 * result. Every decision includes its normalized scope and build channel so
14 * journal readers never infer state from silence.
15 *
16 * This class is policy only. ABJ_404_Solution_DetachAbScope owns what a
17 * workload scope IS and how its keys are derived, and
18 * ABJ_404_Solution_DetachAbAttemptCounter owns where the slot number is stored.
19 */
20 final class ABJ_404_Solution_DetachAbExperiment {
21
22 /** Number of counterbalanced ON/OFF pairs collected per workload scope. */
23 const MAX_PAIRS = 3;
24
25 /** Total toggled attempts one workload scope may consume. */
26 const MAX_ATTEMPTS = self::MAX_PAIRS * 2;
27
28 /**
29 * Whether this build may run the experiment.
30 *
31 * The default is derived from the build's own version rather than set by a
32 * separate wiring step, because that wiring previously failed: the filter
33 * default was hardcoded false, no packaging callback changed it, and the
34 * experiment shipped inert while tests that registered their own callback
35 * still passed. Deriving it from ABJ404_VERSION makes packaging a beta arm
36 * it and packaging a stable release disarm it in one unavoidable step.
37 *
38 * The filter overrides in both directions. A targeted support session on a
39 * stable release can turn the experiment on, while a beta tester who needs
40 * the detach on every request can turn it off without another build.
41 */
42 public static function isEnabled(): bool {
43 $preRelease = ABJ_404_Solution_PluginReleaseChannel::isPreRelease();
44 return (bool)ABJ_404_Solution_ResponseControlFilterTracer::traceDispatch(
45 'abj404_should_run_detach_ab_diagnostic',
46 static function () use ($preRelease) {
47 return apply_filters(
48 'abj404_should_run_detach_ab_diagnostic',
49 $preRelease,
50 array()
51 );
52 }
53 );
54 }
55
56 /**
57 * Return the deterministic mode for one counter slot.
58 *
59 * Each adjacent pair contains one ON and one OFF request. Successive pairs
60 * reverse order, while a stable seed decides the first pair's order. Once a
61 * workload consumes MAX_ATTEMPTS, later requests return to `default`, so a
62 * diagnostic probe never permanently degrades an admin session.
63 */
64 public static function modeForAttempt(int $attemptIndex, string $assignmentSeed = ''): string {
65 if ($attemptIndex < 0 || $attemptIndex >= self::MAX_ATTEMPTS) {
66 return ABJ_404_Solution_DetachAbAttempt::MODE_DEFAULT;
67 }
68 $pairOrdinal = intdiv($attemptIndex, 2);
69 $position = $attemptIndex % 2;
70 $seedByte = hexdec(substr(md5($assignmentSeed), 0, 2));
71 $onFirst = (($seedByte + $pairOrdinal) % 2) === 0;
72 if ($position === 1) {
73 $onFirst = !$onFirst;
74 }
75 return $onFirst
76 ? ABJ_404_Solution_DetachAbAttempt::MODE_ON
77 : ABJ_404_Solution_DetachAbAttempt::MODE_OFF;
78 }
79
80 /**
81 * Create a stable, privacy-safe fingerprint for request-shaping fields.
82 *
83 * @param array<string, scalar|null> $payload
84 */
85 public static function payloadKey(array $payload): string {
86 ksort($payload);
87 return sha1(serialize($payload));
88 }
89
90 /**
91 * Hash a browser session for joins without journaling its raw value.
92 *
93 * The name the diagnostics classes reach for. The formula itself belongs to
94 * ABJ_404_Solution_DetachAbScope, which is what a hashed session identifies;
95 * defining it there and forwarding from here keeps the dependency running
96 * one way (policy depends on the scope, never the reverse) while leaving the
97 * fourteen production callers that only want the hash where they are.
98 */
99 public static function sessionKey(string $sessionId): string {
100 return ABJ_404_Solution_DetachAbScope::sessionKeyFor($sessionId);
101 }
102
103 /**
104 * Reserve this workload's next attempt slot and return its assignment.
105 *
106 * A COMMAND, not a query, and named accordingly since 2026-08-30: calling
107 * this consumes one of the six slots the scope will ever have. It was
108 * called resolve(), which reads as a question, and a reader checking "what
109 * mode is this request in?" twice would have silently burned two thirds of
110 * a counterbalanced pair sequence.
111 *
112 * `inert` is positive evidence that the experiment did not run. `default`
113 * means the bounded experiment finished and the ordinary detach path was
114 * restored. Both retain the same journal shape as an active assignment.
115 *
116 * Build channel travels with every decision because an inert stable build
117 * is correct while an inert pre-release build means the experiment failed
118 * to arm. Session key, part, payload key, ordinal, and pair coordinates also
119 * travel with every result so a reader can join it to the exact counter that
120 * produced the slot rather than infer scope from missing fields.
121 *
122 * @return array<string, mixed>
123 */
124 public static function assignNextAttempt(ABJ_404_Solution_DetachAbScope $scope): array {
125 return ABJ_404_Solution_DetachAbResolutionTracer::traceResolution(
126 $scope,
127 static function () use ($scope): array {
128 $buildChannel = ABJ_404_Solution_PluginReleaseChannel::currentChannel();
129 if (!self::isEnabled()) {
130 return self::inertDecision(false, $buildChannel, $scope);
131 }
132 $attemptIndex = ABJ_404_Solution_DetachAbAttemptCounter::reserveNextSlot($scope);
133 if ($attemptIndex === ABJ_404_Solution_DetachAbAttemptCounter::NO_SLOT) {
134 return self::inertDecision(true, $buildChannel, $scope);
135 }
136 $assignmentSeed = $scope->assignmentSeed();
137 return array(
138 'mode' => self::modeForAttempt($attemptIndex, $assignmentSeed),
139 'attempt_index' => $attemptIndex,
140 'diagnostic_enabled' => true,
141 'build_channel' => $buildChannel,
142 'session_key' => $scope->sessionKey(),
143 'part' => $scope->part(),
144 'payload_key' => $scope->payloadKey(),
145 'ordinal' => $attemptIndex,
146 'pair_ordinal' => intdiv($attemptIndex, 2),
147 'pair_position' => $attemptIndex % 2,
148 'assignment_seed' => $assignmentSeed,
149 );
150 }
151 );
152 }
153
154 /**
155 * @return array<string, mixed>
156 */
157 private static function inertDecision(
158 bool $diagnosticEnabled,
159 string $buildChannel,
160 ABJ_404_Solution_DetachAbScope $scope
161 ): array {
162 return array(
163 'mode' => ABJ_404_Solution_DetachAbAttempt::MODE_INERT,
164 'attempt_index' => ABJ_404_Solution_DetachAbAttemptCounter::NO_SLOT,
165 'diagnostic_enabled' => $diagnosticEnabled,
166 'build_channel' => $buildChannel,
167 'session_key' => $scope->sessionKey(),
168 'part' => $scope->part(),
169 'payload_key' => $scope->payloadKey(),
170 'ordinal' => ABJ_404_Solution_DetachAbAttemptCounter::NO_SLOT,
171 'pair_ordinal' => -1,
172 'pair_position' => -1,
173 'assignment_seed' => '',
174 );
175 }
176 }
177