π§ 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=0is 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
| defect | before | after |
|---|---|---|
| unreadable existing quiz | HTTP 404 + 121,285 B of home page | HTTP 404 + 55,655 B theme 404 document |
| nonexistent quiz id | HTTP 200 + 121,286 B of home page | HTTP 404 + 55,655 B |
| id of a post that is not a quiz | HTTP 200 + 121,284 B of home page | HTTP 404 + 55,655 B |
| readable quiz | 200, player, one title | unchanged |
| readable-but-empty quiz | 200 + unavailable card | unchanged |
| unavailable card on the play route | no border, no background, no icon tile | dashed 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_querystate is set, and only then does empty rendered content substituteQuizEmbed::unavailable()β stays 200. Verified live on a fixture: 200, card present, wrapper present, one title,qwizz-empty.cssloaded, 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 assertsh1=0only on the populated play route (:2364-2365). The fixture was force-deleted afterwards, so anyh1reading 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:
| property | play route | library page 1289 |
|---|---|---|
| border | dashed 1px color(srgb 0.0667 0.0667 0.0667 / 0.2448) | identical |
| background | color(srgb 0.958991 β¦ / 0.9832) | identical |
| color | color(srgb 0.0667 β¦ / 0.72) | identical |
--qw-line2 | color-mix( in srgb, currentColor 34%, transparent ) | identical |
--qw-soft | color-mix( in srgb, currentColor 6%, #FFFFFF ) | identical |
| font-size / family / line-height | 22px / Manrope, sans-serif / 30.8px | identical |
| border-radius / padding / display / gap | 14px / 24px / grid / 8px | identical |
| icon tile | 52Γ52px, 14px radius, painted, shadow present | n/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:
| form | median | note |
|---|---|---|
| shipped (optimizerβs choice) | 38.90ms | runs 35.3 / 38.9 / 40.4 |
STRAIGHT_JOIN from subjects | 34.21ms | runs 32.0 / 34.2 / 34.3 |
| derived valid set | 39.84ms | slower |
filter in WHERE instead of ON | 41.52ms | slower |
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:
| path | time | queries | matrix-query hits |
|---|---|---|---|
| warm transient | 0.17β0.33ms | 0 | 0 |
| stale mirror, transient dropped | 0.58β0.71ms | 0 | 0 |
| post-mutation cold rebuild | 63.36ms | 8 | 1 |
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.
includes/Frontend/QuizQueryViewer.phpβ new privateemit_404()at:64-82, a self-contained helper that declares its ownglobal $wp_queryat:65; both call sites do nothing but invoke it, the not-a-quiz guard at:21and the access-denied branch at:30. No global was hoisted and none is shared: the guard at:21runs before the readable pathβs ownglobal $wp_queryat:23, which is precisely why the helper declares its own.php -lclean.assets/css/qwizz-tokens.cssβ.qwizz-library__emptyadded 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 fourinheritdeclarations 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
| reverted | measured regression | failed |
|---|---|---|
QuizQueryViewer.php | draft 404 + 121,285 B + homepage marker; absent id HTTP 200 + 121,286 B; foreign id 200 + 121,284 B | assertion 74 |
assets/css/qwizz-tokens.css | selector count 3 β 0 | assertion 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, sha25634e2829f64ffad003258625ae15029a7c52669758668f48655cfa99da35d4438, +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-relative2026-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_attempts22,MAX(ID)qw_quiz10989 andqw_question10993 β all identical to the pre-cluster baseline. The empty-content fixture created for the 200-contract check was force-deleted.logs/debug.logunchanged 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.