🎮 2026-08-26 playable quiz repair
Historical record — 2026-08-26
Preserved as observed. Cluster 2 of the ordered series delivered by the 2026-08-27 canonical handoff; the two Cluster 1 findings it left open were both closed on 2026-08-27. Current truth: Program Masterplan.
Scope: WRDP bench, 2026-08-26, Cluster 2. Citations are against the installed source under wp/wp-content/plugins/quizwizz/. Every figure on this page was measured this session; nothing is carried over from a plan, a changelog, or a prior page. The Cluster 1 record is the play surface repair page; the harness contract is the probe harness page.
Findings
Two of the four findings recorded as not fixed on the Cluster 1 page are now fixed. Two were still open when this page was written on 2026-08-26: finding 3 was closed on 2026-08-27 by Cluster 5, and finding 4 remains open.
Fixed:
- every wp-admin Dashboard Play link led to a blank page
- the play route served an empty document for a quiz whose content renders nothing
Open on 2026-08-26, untouched, and explicitly out of scope for this cluster; finding 3’s later closure is recorded inline beneath it:
- CLOSED 2026-08-27 — the play route’s not-found branch was a soft-404.
- observed 2026-08-26:
QuizQueryViewer.php:24-25didstatus_header( 404 ); return;, so WordPress went on to render the site home page body under a 404 status. - that citation addresses the pre-Cluster-5 file and no longer resolves. Live
:23-25readsglobal $wp_query;then theif ( ! QuizAccess::can_read( $id, $token ) ) {guard and itsif ( QuizAccess::password_required( $id ) ) {branch, and the quoted statement appears nowhere in the file. - measured live on draft 11119: HTTP 404, 121285 bytes, served
<title>QuizWizz – Qwizz the Quiz Wizzard</title>. - nothing in this cluster touched that branch; see the non-changes below.
- closed 2026-08-27 by Cluster 5: the not-found path is now
QuizQueryViewer::emit_404()(:64-82), called from the not-a-quiz guard (:21) and from the access-denied branch (:30). It clears$wp_query, callsset_404(), sends the status and renders the theme’s 404 template. All three not-found conditions serve 404 at 55,655 bytes rather than 121,285, and two of the three had also been answering HTTP 200 — the 2026-08-27 not-found and token-scope record.
- observed 2026-08-26:
- CLOSED 2026-08-27 — zero-question
Automatischer Entwurfdraft quizzes remained as bench data pollution.- this cluster deliberately deleted none of them: the fix was presentational, so the rows were merely unlisted rather than gone.
- the zero-question population was counted twice during this cluster and the two counts differed. One measurement stated 109 zero-question
Automatischer Entwurfdrafts; the census below counted 109 drafts in total, of which 15 failqw_question_count > 0. This page recorded both as measured and named the single query that would settle it. - resolved 2026-08-27 by running exactly that query, grouping
qw_quizbypost_statusand by whether the title is literallyAutomatischer Entwurf, joined toqw_question_count. The census figure was right and the other was wrong: the bench held 109 drafts in total, of which exactly 15 carried zero questions. Only 5 of those 15 were titledAutomatischer Entwurf; the rest were 4 EnglishAuto Draft, 5Automatisch gespeicherter Entwurf, and 1 namedAutocool. So the 109 described the whole draft population, never the zero-question subset, and the labelAutomatischer Entwurfcovered a third of it. - closed 2026-08-27 by deleting all 15 rows with
wp post delete --forceafter a full bench backup. Verified afterwards: 94 drafts remain and every one holds questions,zero_qis 0 across everypost_status,wp_qwizz_attemptsstill 22, zero orphanedwp_postmetarows,wp_qwizz_question_indexunchanged at 9,069, and the dashboard aggregate rebuilt itself to 107 quizzes (13 published, 94 draft).MAX(ID)forqw_quizmoved 10989 to 10779 as a result, so bench-state figures dated before this cleanup cite the older maximum correctly.
Root causes, measured
Dashboard:
recent_quizzes()(Admin/DashboardPresenter.php:308) queriedpost_status => array('publish','private','draft','pending','future')(:312), ordered bymodified(:315-316), limitRECENT_LIMIT= 5 (:314), and emitted a Play link wheneverQuizAccess::can_read()passed.- zero-question drafts dominate
orderby=modified, so all five slots were dead links. - measured before the fix, by reflection on the private method with
wp_set_current_user(1):rows=5- every row titled
Automatischer Entwurf - every row’s meta reading
Draft · 0 questions - every row emitting a non-empty Play URL
Play route:
do_blocks()on emptypost_contentreturns nothing, so the route emitted<main class="qwizz-single-quiz" id="qwizz-content"></main>and nothing else.- measured:
/?qwizz_play=10989→ HTTP 200, correct<title>,postid-10989body class, no player, white page.
Quiz-meta census
Measured this session. It is the reason a qw_question_count > 0 clause is safe rather than lossy:
- every
qw_quizpost carries theqw_question_countmeta key, zero rows missing it, so the clause cannot silently drop a row for absent meta - draft 109, of which 94 have count > 0 and 14 hold empty content
- private 2, all count > 0
- publish 13, all count > 0
Net: the clause removes exactly the 15 zero-question rows. The 94 real drafts stay listable because the clause keys on question count, not on post_status: the post_status list is unchanged, so a draft that holds questions is still unfinished work worth surfacing, while a draft that holds none has nothing to play.
Files changed (3)
All bench-local, releaseEligible: false.
includes/Admin/DashboardPresenter.php- the flat
relation => 'OR'ephemeralmeta_queryis now nested underrelation => 'AND'(:320-321), with the ephemeral OR group preserved byte-for-byte (:322-333) and aqw_question_count > 0NUMERIC clause ANDed alongside (:334-339) - the CTA is gated
$play = ( $count > 0 && QuizAccess::can_read( $id ) ) ? … : ''(:350), reusing the$countalready computed two lines above (:348) - the method docblock now states the playability rule (
:304)
- the flat
includes/Frontend/QuizQueryViewer.php$rendered = do_blocks( $post->post_content );is computed before the doctype line (:45, doctype at:49), and'' === trim( $rendered )substitutesQuizEmbed::unavailable()(:46-48)- status stays 200
- that precompute position is load-bearing, not cosmetic; see the section below
includes/Frontend/QuizEmbed.phpunavailable()widened from private to public with a one-line docblock (:63-64), so the route reuses the exact existing card markup and copy (:69) instead of introducing a second empty-state
Deliberately not changed
RECENT_LIMIT(DashboardPresenter.php:314), thepost_statuslist (:312) and the ordering (:315-316): the defect was which rows qualify, not how many or in what order- no view edit was needed.
includes/Admin/views/dashboard-page.php:53already guards<?php if ( '' !== $quiz['play'] ) : ?>, so an empty play string renders no anchor at all - the route’s 404 branch, left exactly as finding 3 recorded it. Its
QuizQueryViewer.php:24-25citation describes the pre-Cluster-5 file; finding 3 itself was closed on 2026-08-27 - no question-count check was added in the route. The substitution keys on rendered output being empty (
:46), which is the condition that actually produced the blank document, and it therefore also covers a quiz whose blocks render to nothing for a reason other than a zero count - no data was deleted: no quiz row, no postmeta row, no option, no term
Why the precompute position is load-bearing
Moving $rendered back inside the document body would silently unstyle the card while every functional assertion still passed.
QuizEmbed::unavailable()callsLibraryShortcode::enqueue_catalog()as its first statement (QuizEmbed.php:65)- the precompute at
QuizQueryViewer.php:45-48therefore runs that enqueue earlier in the request than thewp_head()call on:49, so the catalog stylesheet lands in<head> - enqueueing during body rendering would only have surfaced via
print_late_styles()in the footer - Wave 8 pins the consequence rather than the ordering: assertion 54 asserts
catalog-css=1, and theQuizQueryViewer.phpmutation pass below shows that field dropping tocatalog-css=0
Canonical handoff
Three artifacts sit beside this page in q5vault/audits/qwizz/:
2026-08-26-cluster2-source.patch— 5257 bytes, sha2563d6cf25481e45b40709f7875f1ca4f7f8e958fc18a680884eeb7b3aecafa86d92026-08-26-cluster2-preimage.sha256— 3 pre-fix digests, plugin-root-relative2026-08-26-cluster2-postimage.sha256— 3 post-fix digests, plugin-root-relative
Apply from the plugin root, never with --reject:
git apply -p1 2026-08-26-cluster2-source.patchIntegrity, all measured:
- 3
diff --gitstanzas over the 3 allowlisted paths; zero mode, rename, add, delete or binary stanzas - +27 / −11 lines
- forward round-trip:
--checkrc 0, apply rc 0,diff -rbyte-identical - reverse round-trip:
-R --checkrc 0, apply rc 0,diff -rbyte-identical to the pre-cluster-2 state, so the rollback path is proven rather than assumed sha256sum -cof the postimage sidecar against the live plugin tree: 3/3 OK
Ordering is enforced by the digests, not by prose
- the Cluster 2 preimage digests for
includes/Frontend/QuizQueryViewer.phpandincludes/Frontend/QuizEmbed.phpare identical to the Cluster 1 postimage digests for those files, so Cluster 2 cannot apply cleanly unless Cluster 1 is already applied includes/Admin/DashboardPresenter.phpis new to Cluster 2 and appears in no Cluster 1 digest- so the apply order is a property of the artifacts, not an instruction the release owner has to remember
Wave 8 — playable-quiz contract
Wave 8 was added to scripts/quizwizz-probe.sh, a host-owned harness file, not plugin source. The harness now accepts --wave 1|2|3|4|5|6|7|8|all, and the standing gate moved 1..51 → 1..55, assertions=55 failures=0. Wave 8 holds 4 assertions, TAP 52-55.
ok 52 - dashboard offers only playable quizzes—rows=5 playable=5 dead-play=0ok 53 - zero-question quiz excluded but preserved—excluded=1 preserved=1, against a dynamically selected zero-question quizok 54 - empty-content play route serves the unavailable card—status=200 titles=1 wrapper=1 card=1 unavailable=1 wrapped=1 player-card=0 catalog-css=1ok 55 - populated play route unaffected— quiz 99 still serves the player withdata-autostart="1", one wrapper, zero<h1>, no unavailable card
Assertion 52 was wrong while the fix was right
Recorded as its own finding, because the diagnostic value is the point rather than the eventual green line.
- as first written, assertion 52 demanded
with-play=rows, i.e. that every listed quiz offer a Play link - it passed under
--wave 8alone and failed under--wave allwithwith-play=4 - cause: earlier waves create an access-gated fixture quiz that holds questions but legitimately fails
QuizAccess::can_read(), so it correctly gets no Play CTA - the assertion, not the fix, was wrong. It had encoded “every row is playable”, when the contract is “no row offers a Play link it cannot honour”
- correction: it now pins the defect direction instead —
dead-play=0, the count of rows that do offer a Play link while holding zero questions, with an unresolved row counted as unplayable so a Play link on one still reads as dead - rows offering no Play link are reported in a diagnostic comment line rather than asserted
- an assertion that passes only in isolation is a wave-ordering artifact; running
--wave allbefore trusting a new wave is what exposed it
Fixture construction
Building a publish-status, empty-content quiz required a discovery about the save pipeline:
QuizService::register_hooks()bindssave_post_qw_quiz→manage_native_quizat priority 30 (QuizService.php:32)manage_native_quizcallsforce_draft()for a quiz with no valid questions and back-fills empty content (QuizService.php:503and:524, inside the:500-525block)- so
wp post create --post_status=publishwith empty content silently returnsdraft, and an anonymous fetch of a draft hits the untouched 404 branch, which would have made the probe measure the then-open finding 3 instead of the fixed finding 2 - the probe therefore detaches exactly that one binding, the priority-30
save_post_qw_quiz→manage_native_quizhook, for the single insert
Mutation proof
Run against live code, both passes under an ERR trap with byte-exact restore afterwards:
- revert
DashboardPresenter.phpto its pre-cluster-2 bytes →assertions=4 failures=2- assertion 52 detail
actual=rows=5 playable=0 dead-play=5, the defect stated numerically: five rows, none playable, all five offering dead Play links - assertion 53 detail
actual=excluded=0 preserved=1 - assertions 54 and 55 stayed
ok
- assertion 52 detail
- revert
QuizQueryViewer.phponly, keeping Cluster 1 intact →assertions=4 failures=1- assertion 54 detail
actual=… card=0 unavailable=0 wrapped=0 catalog-css=0, which also proves the stylesheet reaches<head>only because of the precompute - assertions 52, 53 and 55 stayed
ok
- assertion 54 detail
- after each pass:
php -lclean, postimage digests 3/3 OK,--wave 8green again - reverting
QuizEmbed.phpalone is not a valid mutation: a public caller against a private method is a fatal that takes out 54 and 55 together, so it would prove nothing about either
Coverage caveat
Stated rather than hidden.
- Wave 8 pins the query clause.
- It does NOT independently pin the
$count > 0CTA belt atDashboardPresenter.php:350. With the query filter in place a zero-question quiz never reaches the CTA, so that branch is unreachable and a CTA-only revert fails no assertion. - The belt is kept as cheap insurance against a future query change, not as a tested contract.
- What would settle it: an assertion that reaches the CTA with the query clause removed, which means mutating two things at once and is deliberately not part of this gate.
Browser verification
Cache disabled, https://wrdp.loca.zone.
- wp-admin
?page=qw-dashboard:- five Play links, to quizzes 77, 10779, 10728, 10627, 10526
- zero occurrences of
Automatischer Entwurfanywhere on the page - before the fix all five links pointed at zero-question drafts
- clicked the first dashboard Play link as admin →
?qwizz_play=77:Question 1 of 10,General Knowledge Compact- no intro,
autostart=1, one<title> - body
rgb(23,24,29)withdataset.qwizzTheme === 'dark'
- admin view of zero-question draft 10989, the previously blank white page:
- the
Quiz unavailablecard, insidemain.qwizz-single-quiz, no player, exactly one<title> qwizz-embeds.csspresent as a stylesheet link, and the icon SVG rendered- the card computed as
display: grid,padding: 24px,border-radius: 14px,text-align: center, i.e. actually styled rather than bare markup - the page background stays the theme white. No player runs, so no palette is mirrored onto the body; that is the intended
site-like outcome for a surface with no quiz to paint.
- the
Bench state
- every fixture created by the fixes, their probes and the gate was force-deleted; the final census is identical to the pre-cluster-2 census:
qw_quiztotal 124, drafts 109, zero rows above id 11118, zero orphan postmeta- that total does not reproduce later, for a reason unrelated to this cluster: at 21:06 UTC the plugin’s own hourly
qwizz_cleanup_ephemeralcron (Frontend/EphemeralQuizCleanup.php, scheduled inInstaller.php:247-249) hard-deleted two expired ephemeral guest quizzes, bothprivate, taking the total to 122 with drafts unchanged at 109 and zero ephemeral quizzes left. Craft-created guest quizzes areprivateplusqw_ephemeral=1with a clampedqw_expires_at, so this is designed retention, not residue and not a side effect of the fixes. The residue claims that matter — zero rows above id 11118 and zero orphan postmeta — still hold.
- that total does not reproduce later, for a reason unrelated to this cluster: at 21:06 UTC the plugin’s own hourly
- the zero-question control quiz 10989 still exists, still
draft, untouched logs/debug.log38544 lines before and after all Cluster 2 work, including both mutation passes: zero new lines- all changed plugin files remain
www-data:www-data 0644 - no option, term, or pre-existing post was modified; no row was deleted
Release boundary
releaseEligible: false. Only the three plugin files above and the host-owned harness changed. Nothing under vendor/, build/, provenance, release ZIPs, QUIZWIZZ_VERSION or db_data/ was modified. No version bump, no build and no release happened. The release owner applies Cluster 1 first, then Cluster 2, through the locked pipeline in BUILDING.md — an order the digest sidecars enforce on their own, as recorded above. The harness contract this cluster extended is recorded in the probe harness page.