$context Mutated in place by runStage(). * @return array */ public static function dispatchStep(array $stepRequest, array &$context): array { switch ($stepRequest['step']) { case ABJ_404_Solution_CanaryLadderStep::CONCURRENT_CONTROL: case ABJ_404_Solution_CanaryLadderStep::AUTH_ONLY: return self::runFillerStep($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::SIZE_TARGET: return self::resolveSizeTarget($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::BASELINE_CONTROL: return self::runBaselineControlStep($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::POST_LIMITER: return self::runPostLimiterStep($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::SUMMARY: return self::runSummaryStep($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::SIZE_PROBE: return self::runSizeProbeStep($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::INERT: return self::runInertStep($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::COMPRESS_ON: case ABJ_404_Solution_CanaryLadderStep::COMPRESS_OFF: return self::runCompressionStep($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::STREAM: return self::runStreamStep($stepRequest, $context); case ABJ_404_Solution_CanaryLadderStep::INTERPRET: return self::runInterpretStep($stepRequest, $context); default: return array(); } } /** * The browser session this request belongs to, or ''. * * One place rather than the three call sites that each re-derived it: the * context is a loose array, so every re-derivation is another chance to * disagree about what a non-scalar session id means. * * @param array $context */ private static function sessionIdFrom(array $context): string { $raw = $context['session_id'] ?? ''; return is_scalar($raw) ? (string)$raw : ''; } /** * The plain reference probe: a fixed-size filler payload and nothing else. * * Serves both `auth_only` (the ladder's floor: what an authenticated * round trip costs with no work in it) and `concurrent_control` (the same * payload, issued alongside a real request, to separate contention from * size). * * @param CanaryStepRequest $stepRequest * @param array $context * @return array */ private static function runFillerStep(array $stepRequest, array &$context): array { $requestId = $stepRequest['request_id']; $step = $stepRequest['step']; return ABJ_404_Solution_AjaxStageDiagnostics::runStage($context, 'canary_' . $step, static function () use ($requestId, $step) { return ABJ_404_Solution_AjaxCanaryPayloadFactory::buildFiller( $requestId, $step, ABJ_404_Solution_AjaxCanaryPayloadFactory::AUTH_ONLY_BYTES); }); } /** * Repeated fixed-size reference with an exact, bounded ordinal. * * @param CanaryStepRequest $stepRequest * @param array $context * @return array */ private static function runBaselineControlStep(array $stepRequest, array &$context): array { $requestId = $stepRequest['request_id']; $rawOrdinal = $stepRequest['request_reader']->getPostOrGetSanitize('baselineOrdinal', '0'); return ABJ_404_Solution_AjaxStageDiagnostics::runStage( $context, 'canary_baseline_control', static function () use ($requestId, $rawOrdinal) { return ABJ_404_Solution_AjaxCanaryPayloadFactory::buildBaselineControl( $requestId, $rawOrdinal ); } ); } /** * The rate limiter's own cost, isolated from its enforcement. * * @param CanaryStepRequest $stepRequest * @param array $context * @return array */ private static function runPostLimiterStep(array $stepRequest, array &$context): array { $requestId = $stepRequest['request_id']; return ABJ_404_Solution_AjaxStageDiagnostics::runStage($context, 'canary_post_limiter', static function () use ($requestId) { // Same limiter call the real table endpoint makes, on // its own bucket with a ceiling high enough to never // actually trip: this step measures the limiter's own // overhead, not its enforcement. ABJ_404_Solution_Ajax_Php::consumeRateLimit('canary_ladder_probe', 6000, 60); return ABJ_404_Solution_AjaxCanaryPayloadFactory::buildFiller( $requestId, ABJ_404_Solution_CanaryLadderStep::POST_LIMITER, ABJ_404_Solution_AjaxCanaryPayloadFactory::AUTH_ONLY_BYTES); }); } /** * One real count query, to price the database independently of payload size. * * @param CanaryStepRequest $stepRequest * @param array $context * @return array */ private static function runSummaryStep(array $stepRequest, array &$context): array { $subpage = $stepRequest['subpage']; return ABJ_404_Solution_AjaxStageDiagnostics::runStage($context, 'canary_summary', static function () use ($subpage) { /** @var ABJ_404_Solution_ViewReadServiceInterface $viewReadService */ $viewReadService = abj_service('view_read_service'); $counts = $subpage === 'abj404_captured' ? $viewReadService->getCapturedStatusCounts() : $viewReadService->getRedirectStatusCounts(); return array('summaryTotal' => (int)($counts['all'] ?? 0)); }); } /** * Size-axis probe; all inputs are normalized before timing opens. * * @param CanaryStepRequest $stepRequest * @param array $context * @return array */ private static function runSizeProbeStep(array $stepRequest, array &$context): array { $requestId = $stepRequest['request_id']; $requestReader = $stepRequest['request_reader']; $options = ABJ_404_Solution_AjaxCanaryPayloadFactory::normalizeSizeProbeOptions( $requestId, $requestReader->getPostOrGetSanitize('payloadBytes', ''), $requestReader->getPostOrGetSanitize('payloadVariant', ''), $requestReader->getPostOrGetSanitize('payloadRungPercent', ''), $requestReader->getPostOrGetSanitize('targetBytesSource', '') ); return ABJ_404_Solution_AjaxStageDiagnostics::runStage($context, 'canary_size_probe', static function () use ($options) { return ABJ_404_Solution_AjaxCanaryPayloadFactory::buildVariant($options); }); } /** * A payload of the same size as the size probe, built with no work at all. * * The control that separates "this size is slow to send" from "this size is * slow to BUILD". * * @param CanaryStepRequest $stepRequest * @param array $context * @return array */ private static function runInertStep(array $stepRequest, array &$context): array { $requestId = $stepRequest['request_id']; $bytes = ABJ_404_Solution_AjaxCanaryPayloadFactory::clampTargetBytes( $stepRequest['request_reader']->getPostOrGetSanitize('payloadBytes', '')); return ABJ_404_Solution_AjaxStageDiagnostics::runStage($context, 'canary_inert', static function () use ($requestId, $bytes) { return ABJ_404_Solution_AjaxCanaryPayloadFactory::buildFiller( $requestId, ABJ_404_Solution_CanaryLadderStep::INERT, $bytes); }); } /** * The same payload with compression asked for and then asked against. * * Serves both `compress_on` and `compress_off`; the `off` half actively * suppresses transformation so the two are genuinely different requests * rather than two names for whatever the host decided to do. * * @param CanaryStepRequest $stepRequest * @param array $context * @return array */ private static function runCompressionStep(array $stepRequest, array &$context): array { $requestId = $stepRequest['request_id']; $step = $stepRequest['step']; $bytes = ABJ_404_Solution_AjaxCanaryPayloadFactory::clampTargetBytes( $stepRequest['request_reader']->getPostOrGetSanitize('payloadBytes', '')); return ABJ_404_Solution_AjaxStageDiagnostics::runStage($context, 'canary_' . $step, static function () use ($requestId, $step, $bytes) { $fields = array('compressionMode' => $step); if ($step === ABJ_404_Solution_CanaryLadderStep::COMPRESS_OFF) { // Both halves of the suppression are refusable at runtime // and neither refusal is visible to a caller that discards // the outcome, so the outcome rides the payload: a rung // that suppressed nothing must not be readable as one that // did. $fields['compressionSuppression'] = ABJ_404_Solution_ResponseCompressionSuppression::apply(); } // Through $extraFields rather than assigned after the build, so // the filler absorbs their cost and both rungs encode to the // same number of bytes. Assigned after, every field is EXTRA // bytes past the target: one byte while `compressionMode` was // alone, 98 once the suppression outcome joined it -- the // size-controlled comparison drifting in size because of what // it reports about itself. return ABJ_404_Solution_AjaxCanaryPayloadFactory::buildFiller( $requestId, $step, $bytes, $fields); }); } /** * The mid-response flush probe: the only step that puts bytes on the wire * before the JSON body exists. * * It decides whether a flush can reach the client at all on this host, * performs it, and journals findings about it under two different record * kinds -- substantially more than the single call each arm of * dispatchStep() makes, which is why it has its own method like every * other step. * * @param CanaryStepRequest $stepRequest * @param array $context Mutated in place by runStage(). * @return array */ private static function runStreamStep(array $stepRequest, array &$context): array { $requestId = $stepRequest['request_id']; $obLevelBefore = ABJ_404_Solution_ExactInteger::readOr( $context['ob_level_before'] ?? null, 0, 0 ); $streamSessionKey = ABJ_404_Solution_DetachAbExperiment::sessionKey( self::sessionIdFrom($context)); return ABJ_404_Solution_AjaxStageDiagnostics::runStage($context, 'canary_stream', static function () use ($requestId, $obLevelBefore, $streamSessionKey) { // Routed through the same output-buffer-management // filter every other flush in this codebase respects, // so a host or test that turns that management off // never gets a mid-response flush it did not ask for. // // The plan decides whether a whitespace block is // emitted at all. ob_flush() only moves the CURRENT // buffer into its PARENT, so behind any foreign // buffer this step used to prefix the body with 2048 // non-JSON bytes that reached no client, escaped the // plugin's own containment floor, and confounded the // comparison against `inert`. See // ABJ_404_Solution_AjaxCanaryStreamFlushPlan::fromObservation(). // // around()-bracketed rather than announced by a bare // pre-call record (gap-hunt iteration 2, the same // Codex gap #5 shape fixed in AjaxResponseEmitter's // ob_close): a stall inside ob_flush()/flush() behind // a buffering intermediary is exactly what this canary // step exists to detect, and a record with no matching // end could only ever prove a flush was ATTEMPTED. // 'flushed' keeps the skip branch positive evidence // instead of an absence -- without it an elapsed of 0 // reads as an instant flush rather than no flush. $manageOutputBuffer = (bool)apply_filters( 'abj404_should_manage_output_buffer', true, array('source' => 'canaryLadder_stream')); $plan = ABJ_404_Solution_AjaxCanaryStreamFlushPlan::fromObservation(array( 'manage_output_buffer' => $manageOutputBuffer, 'ob_level_before' => $obLevelBefore, 'ob_level_now' => ob_get_level(), )); $flushOutcome = ABJ_404_Solution_AjaxCanaryStreamFlush::emitAndFlush( $plan, ABJ_404_Solution_AjaxCanaryStreamFlush::WHITESPACE_BYTES, static function (): void { ABJ_404_Solution_JsonResponseHead::emitEarly(200); }, static function (callable $work, array $startFields) use ($requestId): void { ABJ_404_Solution_AjaxCheckpointLogger::around( $requestId, 'canary_stream_first_flush', $work, $startFields); } ); // The response declares the exact prefix it intentionally put // on the wire. The delivery analyzer then proves those bytes // arrived unchanged; a swallowed/coalesced flush becomes a // failed control, while every undeclared prefix stays corrupt. $flushOutcome['deliveryPrefix'] = array( 'bytes' => $flushOutcome['streamWhitespaceBytes'], 'byte' => ' ', ); // The findings ride their own record rather than the // bracket's end record: a support payload is read // long after the run, and "did this step stream, and // if not why not" is the one thing that decides // whether its outcome is evidence about streaming at // all. // // The record names its OWN browser session, so // ABJ_404_Solution_ResponseBodyDeliveryEvidence can // scope it without waiting for the browser's receipt // for this step to arrive. Requiring the receipt would // make "did this step stream" depend on the very // delivery channel under diagnosis -- the same // cross-channel join that reported "no_receipts" over // a complete set of them on the 2026-08-27 Azure // capture. It rides the JOURNAL record only, not the // response payload: the browser already knows its own // session and the step stays byte-matched with its // siblings. ABJ_404_Solution_AjaxCheckpointLogger::record( $requestId, // The constant, not the literal it used to repeat: the // reader below matches on this exact string, so a rename // that missed one copy would silently split producer from // consumer and the outcome would simply stop being found. ABJ_404_Solution_BodyDeliveryObservations::STREAM_FLUSH_EVENT, array_merge($flushOutcome, array('session_key' => $streamSessionKey))); return ABJ_404_Solution_AjaxCanaryPayloadFactory::buildFiller( $requestId, ABJ_404_Solution_CanaryLadderStep::STREAM, ABJ_404_Solution_AjaxCanaryPayloadFactory::AUTH_ONLY_BYTES, $flushOutcome); }); } /** * The ladder's closing step: parse the browser's observations, then hand * off to the verdict join. * * The verdicts themselves live in ABJ_404_Solution_CanaryLadderVerdicts, * which reads the durable journals and decides what both sides' records * mean together. This method owns the orchestration explicitly: read the * closing payload, record its diagnostics once, then return it. Its other * share is the request boundary -- bounding and decoding the observations * payload, and opening the trace stage the command annotates. * * @param CanaryStepRequest $stepRequest * @param array $context * @return array */ private static function runInterpretStep(array $stepRequest, array &$context): array { $requestId = $stepRequest['request_id']; $requestReader = $stepRequest['request_reader']; // Read AND bounded by the normalizer, so the ceiling reaches the raw // payload before any per-byte normalization does. Why that ordering // matters is on decodeBoundedJsonRequestValue() itself. $parsed = ABJ_404_Solution_RequestInputNormalizer::decodeBoundedJsonRequestValue(array( 'name' => 'observations', 'max_bytes' => self::MAX_INTERPRETATION_BYTES, 'unavailable_label' => 'Interpretation unavailable: observations', )); $realFailed = (string)$requestReader->getPostOrGetSanitize('realRequestFailed', '1') !== '0'; $sessionId = self::sessionIdFrom($context); return ABJ_404_Solution_AjaxStageDiagnostics::runStage($context, 'canary_interpret', static function () use ($parsed, $realFailed, $requestId, $sessionId) { $payload = ABJ_404_Solution_CanaryLadderVerdicts::readEvidencePayload(array( 'session_id' => $sessionId, 'parsed' => $parsed, 'real_request_failed' => $realFailed, )); ABJ_404_Solution_CanaryLadderVerdicts::recordVerdictsAndAnnotateStage(array( 'request_id' => $requestId, 'payload' => $payload, )); return $payload; }); } /** * The size axis's calibration point, resolved by * ABJ_404_Solution_CanarySizeTarget. * * The only arm of the ladder that emits no payload and times no round * trip, so the decision it makes lives beside the two size sources it * arbitrates rather than among the probes that consume its answer. * * @param CanaryStepRequest $stepRequest * @param array $context Mutated in place by the build's stages. * @return array */ private static function resolveSizeTarget(array $stepRequest, array &$context): array { return ABJ_404_Solution_CanarySizeTarget::resolve(array( 'subpage' => $stepRequest['subpage'], 'session_id' => self::sessionIdFrom($context), ), $context); } }