🎮 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:

  1. every wp-admin Dashboard Play link led to a blank page
  2. 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:

  1. CLOSED 2026-08-27 — the play route’s not-found branch was a soft-404.
    • observed 2026-08-26: QuizQueryViewer.php:24-25 did status_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-25 reads global $wp_query; then the if ( ! QuizAccess::can_read( $id, $token ) ) { guard and its if ( 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, calls set_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.
  2. CLOSED 2026-08-27 — zero-question Automatischer Entwurf draft 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 Entwurf drafts; the census below counted 109 drafts in total, of which 15 fail qw_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_quiz by post_status and by whether the title is literally Automatischer Entwurf, joined to qw_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 titled Automatischer Entwurf; the rest were 4 English Auto Draft, 5 Automatisch gespeicherter Entwurf, and 1 named Autocool. So the 109 described the whole draft population, never the zero-question subset, and the label Automatischer Entwurf covered a third of it.
    • closed 2026-08-27 by deleting all 15 rows with wp post delete --force after a full bench backup. Verified afterwards: 94 drafts remain and every one holds questions, zero_q is 0 across every post_status, wp_qwizz_attempts still 22, zero orphaned wp_postmeta rows, wp_qwizz_question_index unchanged at 9,069, and the dashboard aggregate rebuilt itself to 107 quizzes (13 published, 94 draft). MAX(ID) for qw_quiz moved 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) queried post_status => array('publish','private','draft','pending','future') (:312), ordered by modified (:315-316), limit RECENT_LIMIT = 5 (:314), and emitted a Play link whenever QuizAccess::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 empty post_content returns 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-10989 body 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_quiz post carries the qw_question_count meta 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' ephemeral meta_query is now nested under relation => 'AND' (:320-321), with the ephemeral OR group preserved byte-for-byte (:322-333) and a qw_question_count > 0 NUMERIC clause ANDed alongside (:334-339)
    • the CTA is gated $play = ( $count > 0 && QuizAccess::can_read( $id ) ) ? … : '' (:350), reusing the $count already computed two lines above (:348)
    • the method docblock now states the playability rule (:304)
  • includes/Frontend/QuizQueryViewer.php
    • $rendered = do_blocks( $post->post_content ); is computed before the doctype line (:45, doctype at :49), and '' === trim( $rendered ) substitutes QuizEmbed::unavailable() (:46-48)
    • status stays 200
    • that precompute position is load-bearing, not cosmetic; see the section below
  • includes/Frontend/QuizEmbed.php
    • unavailable() 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), the post_status list (: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:53 already 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-25 citation 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() calls LibraryShortcode::enqueue_catalog() as its first statement (QuizEmbed.php:65)
  • the precompute at QuizQueryViewer.php:45-48 therefore runs that enqueue earlier in the request than the wp_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 the QuizQueryViewer.php mutation pass below shows that field dropping to catalog-css=0

Canonical handoff

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

  • 2026-08-26-cluster2-source.patch — 5257 bytes, sha256 3d6cf25481e45b40709f7875f1ca4f7f8e958fc18a680884eeb7b3aecafa86d9
  • 2026-08-26-cluster2-preimage.sha256 — 3 pre-fix digests, plugin-root-relative
  • 2026-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.patch

Integrity, all measured:

  • 3 diff --git stanzas over the 3 allowlisted paths; zero mode, rename, add, delete or binary stanzas
  • +27 / −11 lines
  • forward round-trip: --check rc 0, apply rc 0, diff -r byte-identical
  • reverse round-trip: -R --check rc 0, apply rc 0, diff -r byte-identical to the pre-cluster-2 state, so the rollback path is proven rather than assumed
  • sha256sum -c of 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.php and includes/Frontend/QuizEmbed.php are 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.php is 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..511..55, assertions=55 failures=0. Wave 8 holds 4 assertions, TAP 52-55.

  • ok 52 - dashboard offers only playable quizzesrows=5 playable=5 dead-play=0
  • ok 53 - zero-question quiz excluded but preservedexcluded=1 preserved=1, against a dynamically selected zero-question quiz
  • ok 54 - empty-content play route serves the unavailable cardstatus=200 titles=1 wrapper=1 card=1 unavailable=1 wrapped=1 player-card=0 catalog-css=1
  • ok 55 - populated play route unaffected — quiz 99 still serves the player with data-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 8 alone and failed under --wave all with with-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 all before 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() binds save_post_qw_quizmanage_native_quiz at priority 30 (QuizService.php:32)
  • manage_native_quiz calls force_draft() for a quiz with no valid questions and back-fills empty content (QuizService.php:503 and :524, inside the :500-525 block)
  • so wp post create --post_status=publish with empty content silently returns draft, 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_quizmanage_native_quiz hook, for the single insert

Mutation proof

Run against live code, both passes under an ERR trap with byte-exact restore afterwards:

  • revert DashboardPresenter.php to 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
  • revert QuizQueryViewer.php only, 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
  • after each pass: php -l clean, postimage digests 3/3 OK, --wave 8 green again
  • reverting QuizEmbed.php alone 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 > 0 CTA belt at DashboardPresenter.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 Entwurf anywhere 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) with dataset.qwizzTheme === 'dark'
  • admin view of zero-question draft 10989, the previously blank white page:
    • the Quiz unavailable card, inside main.qwizz-single-quiz, no player, exactly one <title>
    • qwizz-embeds.css present 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.

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_quiz total 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_ephemeral cron (Frontend/EphemeralQuizCleanup.php, scheduled in Installer.php:247-249) hard-deleted two expired ephemeral guest quizzes, both private, taking the total to 122 with drafts unchanged at 109 and zero ephemeral quizzes left. Craft-created guest quizzes are private plus qw_ephemeral=1 with a clamped qw_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.
  • the zero-question control quiz 10989 still exists, still draft, untouched
  • logs/debug.log 38544 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.