🚧 2026-08-27 not-found contract and empty-card token scope

Historical record β€” 2026-08-27

Preserved as observed. Its Wave 10 gate 1..76 failures=0 is the standing gate. Current truth: Program Masterplan.

Scope: WRDP bench, 2026-08-27, Cluster 5. Two files changed. Citations are against the installed source under wp/wp-content/plugins/quizwizz/. Every figure was measured this session. This cluster closes the oldest open Cluster 1 finding and the newest Cluster 4 finding, and records a measured decision not to pursue a third item. The handoff vehicle is the canonical handoff record.

Results

defectbeforeafter
unreadable existing quizHTTP 404 + 121,285 B of home pageHTTP 404 + 55,655 B theme 404 document
nonexistent quiz idHTTP 200 + 121,286 B of home pageHTTP 404 + 55,655 B
id of a post that is not a quizHTTP 200 + 121,284 B of home pageHTTP 404 + 55,655 B
readable quiz200, player, one titleunchanged
readable-but-empty quiz200 + unavailable cardunchanged
unavailable card on the play routeno border, no background, no icon tiledashed border, soft background, 52px icon tile with shadow

βˆ’65,630 bytes, βˆ’54%, on every not-found request.

The soft-404 was worse than the finding said

The Cluster 1 finding recorded one condition: an unreadable quiz served the home page body under a 404 status. Verifying the fix turned up two more, both in the guard that returns before the access check ever runs:

  • ?qwizz_play=<id that no post has> answered HTTP 200 with the full home page. This is the case an id scanner hits most often, and its status was not merely cosmetic β€” a 200 tells a crawler the page is real.
  • ?qwizz_play=<id of a page> did the same.

The original finding also could not be reproduced on the id it named. Draft 11119 no longer exists, so the request fell through the earlier guard and returned 200 β€” which is how the extra conditions surfaced at all. Draft 10989 was used as the live control instead.

QuizQueryViewer.php now routes all three through one emit_404() helper: clear the stale loop out of $wp_query, set_404(), status_header( 404 ), nocache_headers(), include the theme’s own get_404_template(), and exit so exactly one document is emitted. A bare status_header( 404 ) was the whole defect β€” it left the main query untouched, so WordPress carried on and painted the home page underneath it.

The two branches that must not converge stay separate, and the distinction is the Cluster 2 contract:

  • not found β€” QuizAccess::can_read() is false and no password is required β†’ 404. The branch exits, so it can never reach the empty-render path.
  • readable but empty β€” can_read() returned true, status_header( 200 ) has already been sent, singular $wp_query state is set, and only then does empty rendered content substitute QuizEmbed::unavailable() β†’ stays 200. Verified live on a fixture: 200, card present, wrapper present, one title, qwizz-empty.css loaded, catalog sheet absent β€” exactly the fields wave 8 pins for this fixture (scripts/quizwizz-probe.sh:2352-2354). Heading absence is not one of them: wave 8 asserts h1=0 only on the populated play route (:2364-2365). The fixture was force-deleted afterwards, so any h1 reading for the empty route is a one-off observation the gate does not pin and nothing re-checks.

The card never painted, on any route, until now

Cluster 4 found this and left it open. assets/css/qwizz-tokens.css declares its public-surface custom properties on a seven-selector list β€” .qwizz-builder, .qwizz-player, .qwizz-editor, .qwizz-catalog, .qwizz-subjects, .qwizz-user, .qwizz-library. The standalone play route substitutes the unavailable card with none of those ancestors, so every var( --qw-… ) in qwizz-empty.css was invalid at computed-value time and each declaration using one was dropped.

The fix adds .qwizz-library__empty to that list, making the card its own token root. What made it non-trivial is the cascade: a custom property declared on an element outranks one inherited from an ancestor, so adding the card to the base list alone would have flipped the library-page card off the inherited color-mix() token stream onto the flat rgba() fallbacks β€” a visible regression on a surface that was already correct. The card was therefore added to all three cascade layers, and the four non-custom declarations in that rule (color, font-family, font-size, line-height, all inherit) were split onto the original seven-selector list so the card’s typography keeps coming from the surface it renders in rather than resolving at selector specificity.

Measured on both surfaces, cache disabled:

propertyplay routelibrary page 1289
borderdashed 1px color(srgb 0.0667 0.0667 0.0667 / 0.2448)identical
backgroundcolor(srgb 0.958991 … / 0.9832)identical
colorcolor(srgb 0.0667 … / 0.72)identical
--qw-line2color-mix( in srgb, currentColor 34%, transparent )identical
--qw-softcolor-mix( in srgb, currentColor 6%, #FFFFFF )identical
font-size / family / line-height22px / Manrope, sans-serif / 30.8pxidentical
border-radius / padding / display / gap14px / 24px / grid / 8pxidentical
icon tile52Γ—52px, 14px radius, painted, shadow presentn/a β€” that state has no icon

Every shared property is byte-identical, which is the regression proof: the library rendering did not shift.

Under forced-colors: active (CDP emulation) both surfaces return --qw-line: CanvasText, --qw-line2: CanvasText, --qw-soft: Canvas, --qw-card: Canvas, black border, white background β€” identical to each other. Without the third cascade layer the card’s own declarations would have outranked those floors.

Decision β€” the 37ms dashboard aggregate stays

Cluster 4 missed two millisecond targets, and the whole gap sits in one query: the subject/language matrix in AdminStatistics.php:336. Three options were measured before deciding, rather than assumed.

No index is missing. EXPLAIN shows the optimizer already picks the right plan: it drives from question_index on the is_valid_lang key added in Cluster 4 with Using index (covering), then joins to question_subjects by primary key at one row per lookup. possible_keys lists both candidates and it chose correctly. The residual Using temporary; Using filesort is inherent to a GROUP BY whose two keys come from two different tables, and it materialises only the 712 real (slug, lang) pairs.

Reformulation buys 4.7ms and costs portability. Medians of three:

formmediannote
shipped (optimizer’s choice)38.90msruns 35.3 / 38.9 / 40.4
STRAIGHT_JOIN from subjects34.21msruns 32.0 / 34.2 / 34.3
derived valid set39.84msslower
filter in WHERE instead of ON41.52msslower

STRAIGHT_JOIN is a genuine win β€” the run ranges do not overlap. It is still rejected: it permanently pins the join order in a plugin that ships to arbitrary installs, and the drive side that wins here depends entirely on this bench’s 9,315-subject-rows to 9,069-valid-questions ratio. On an install with a very different ratio, forcing the subject-side drive is the wrong plan and there is no longer an optimizer to notice. 4.7ms is not worth surrendering that.

A rollup table is rejected on cost, not difficulty. It would buy the full ~37ms, and it would add a sixteenth table, a sync path on every question-index change, a drift surface between the rollup and its source, an uninstall teardown entry, and a migration β€” to speed up work that Cluster 4’s stale-while-refresh already moved out of the interactive request.

What the target should actually have measured. The 35ms and 65ms figures were authored against the pre-Cluster-4 architecture, where every TTL lapse paid the rebuild synchronously in front of a user. That is no longer true: a TTL lapse now serves the mirror in 2.23ms and refreshes in cron. The one remaining synchronous interactive path is the first admin view after a content mutation, because invalidate() clears both stores by design β€” measured at 72.9ms / 8 queries (runs 62.0 / 72.9 / 122.7). For comparison, on this same bench the settings page is 112ms and front-end pages run 114–390ms. A once-per-mutation 72.9ms admin rebuild is not the slowest thing here and does not justify a new table.

The premise was then verified against the query log, not just the clock. Every figure above is a timing, and a timing cannot distinguish β€œthe query is fast” from β€œthe query never ran”. Counting occurrences of question_subjects in $wpdb->queries per payload() call settles it:

pathtimequeriesmatrix-query hits
warm transient0.17–0.33ms00
stale mirror, transient dropped0.58–0.71ms00
post-mutation cold rebuild63.36ms81

The matrix query is reached on exactly one path. payload() has three exits β€” warm transient (AdminStatistics.php:157), stale mirror plus a scheduled cron refresh (:162), and only then a synchronous build() β€” and build_capacity_section() is the sole caller of the aggregate. So a rollup table or a further index would be optimising work that runs once per content mutation, not once per request. That is the whole basis for rejecting both, and it is now falsifiable rather than inferred.

The cold rebuild also repopulates the mirror (generated_at age 0s), so the next TTL lapse is served from it at sub-millisecond cost rather than rebuilding again. An earlier version of this probe reported the mirror as absent after the rebuild, which would have been a real defect β€” a mutation would have forced every subsequent view to rebuild synchronously until cron caught up. That reading was a probe error: it read qwizz_admin_statistics_stale_v1, while the constant at :42 is qwizz_admin_statistics_stale with no version suffix. Recorded because the false alarm and the genuine defect are indistinguishable from the timing alone.

Recorded as a closed decision. Audit rank 11’s index landed in Cluster 4; the aggregate itself is deliberately left as measured.

Files changed (2)

Bench-local, releaseEligible: false, staged under /tmp/qwfix/ and installed with sudo -n install -o www-data -g www-data -m 0644.

  1. includes/Frontend/QuizQueryViewer.php β€” new private emit_404() at :64-82, a self-contained helper that declares its own global $wp_query at :65; both call sites do nothing but invoke it, the not-a-quiz guard at :21 and the access-denied branch at :30. No global was hoisted and none is shared: the guard at :21 runs before the readable path’s own global $wp_query at :23, which is precisely why the helper declares its own. php -l clean.
  2. assets/css/qwizz-tokens.css β€” .qwizz-library__empty added as a token root in the base public-surface rule, the @supports ( color-mix ) refinement block and the @media ( forced-colors: active ) floor block; the four inherit declarations split onto the original surface-root list.

includes/Frontend/QuizEmbed.php was not changed: the fix is entirely in the token scope, so no markup moved and the shared empty-state convention was not forked.

Wave 10 β€” not-found contract and token scope

Wave 10 adds 4 assertions, TAP 73-76. The standing gate moved 1..72 β†’ 1..76, assertions=76 failures=0. It creates nothing: the unreadable control quiz is discovered rather than hardcoded, because the bench’s unreadable population is auto-draft rows that WordPress itself prunes, and the absent id is derived as MAX(ID) + 100000.

Assertions: the discovered control quiz is genuinely unreadable; all three not-found conditions return 404 with zero home-page markers and a body under 80,000 bytes; the readable quiz is unaffected; and .qwizz-library__empty appears as a selector in all three cascade layers. That last one counts selector positions only β€” a bare substring count passes on the explanatory comment alone, which is how it first failed at total=4.

The browser stays authoritative for the paint. The shell can prove the three cascade layers exist; only Chromium can prove the border resolves.

Mutation proof

revertedmeasured regressionfailed
QuizQueryViewer.phpdraft 404 + 121,285 B + homepage marker; absent id HTTP 200 + 121,286 B; foreign id 200 + 121,284 Bassertion 74
assets/css/qwizz-tokens.cssselector count 3 β†’ 0assertion 76

Both restored byte-exact. The reverted draft figure reproduces the Cluster 1 measurement of 121,285 bytes exactly, which independently confirms that the finding described this code. The token revert leaves zero selector positions, not one: all four occurrences of .qwizz-library__empty in qwizz-tokens.css β€” the three cascade-layer selectors and the comment that explains them β€” are Cluster 5 additions, so the pristine file mentions the card nowhere at all.

Canonical handoff

Three artifacts sit beside this page in q5vault/audits/qwizz/:

  • 2026-08-27-cluster5-source.patch β€” 5,855 bytes, sha256 34e2829f64ffad003258625ae15029a7c52669758668f48655cfa99da35d4438, +61 / βˆ’8 lines across 2 files (changed diff lines with blank lines excluded; this patch changes no blank line, so its raw diff is the same +61 / βˆ’8 β€” the convention matters for Cluster 4, whose published +210 / βˆ’72 is +219 / βˆ’75 raw)
  • 2026-08-27-cluster5-preimage.sha256 β€” the 2 pre-fix digests, plugin-root-relative
  • 2026-08-27-cluster5-postimage.sha256 β€” the 2 post-fix digests, plugin-root-relative; re-checked against the live plugin tree, 2/2 identical

Applied with patch -p1 from the plugin root, last of the four clusters; the ordered applier 2026-08-27-apply-clusters.sh and the six digest links that enforce that order are recorded in the canonical handoff record.

Bench state

  • wp_qwizz_attempts 22, MAX(ID) qw_quiz 10989 and qw_question 10993 β€” all identical to the pre-cluster baseline. The empty-content fixture created for the 200-contract check was force-deleted.
  • logs/debug.log unchanged at 38,544 lines.
  • schema 1.6.8, unchanged by this cluster.
  • --wave all β†’ 1..76 failures=0.

Release boundary

releaseEligible: false. Two plugin files plus the host-owned harness. Nothing under vendor/, build/, provenance, release ZIPs, QUIZWIZZ_VERSION or db_data/ was touched, and no version was bumped. This cluster is the fourth and last entry in the ordered series described in the canonical handoff record; its preimage digest for QuizQueryViewer.php equals Cluster 2’s postimage and for qwizz-tokens.css equals Cluster 1’s postimage, so the ordering is enforced by digest.