v4.39 — Mutation Re-Entry Guard Sweep
A reactive
:disabledbinding doesn't stop a same-tick double dispatch — this milestone found and closed every confirmed instance of that gap, and added a CI gate + a live verification harness so the class can't silently regrow the way it did after v4.37.
Summary
v4.37 Phase 3 reported "26 unguarded mutation call sites" as a byproduct of unrelated work — the number was written down in four places and enforced nowhere. Nothing stopped that count from regrowing, and its own reconciliation was ambiguous (one reading said "sites added since 2026-07-08," another said "pre-existing"), so there was no way to check the number against anything. v4.39 replaced it with a reproducible, committed rule.
Phase 1 shipped scripts/census-mutation-guards.mjs — a deterministic scan of ionic_frontend/src for the "sets an in-flight flag but never reads it in an early-return guard" shape — and a fully-triaged inventory of everything it found: 33 confirmed sites, reconciled against neither the old 26 nor a cruder 40-site re-census, both of which measured a different population. Phases 2 and 3 fixed 30 of those 33 (three were deliberately left unguarded — backend-verified idempotent, or a script false positive already guarded via an indirect computed read) across four PR batches, plus 4 further sites found by a mid-milestone scope amendment: handlers with no manual in-flight ref at all, invisible to the census rule's own detection signature, relying solely on Apollo's own unread loading. Two files (useCommunity.ts, MeetCoach.vue) turned out to share one in-flight ref across multiple independent actions — fixed with per-action refs instead of a naive shared guard, which would have made unrelated actions cross-block each other. Phase 4 closed a related gap in useWhatsNew.ts's epoch-seed watcher, and its own first attempt shipped a new instance of the same bug class — caught in review, not by its own passing unit tests. Phase 5 wired the census into a CI recurrence gate that fails the check on any future unguarded site outside a committed, individually-reasoned baseline of the 6 sites Phase 3 declined to fix — not yet a required status check (OBJ-2708), so it reports today rather than blocks. Phase 6 wrote the pattern down so the next contributor finds the rule before writing the bug again. Phase 7 promoted the integration branch to master, recovering cleanly from a promotion PR that squash-merged in error before it could break the milestone's own ancestor-SHA verification.
Goal
The next contributor writing a mutation handler finds the rule before writing the bug, and the milestone has its narrative — closing v4.37 Phase 3's unenforced "26 unguarded sites" gap with a reproducible census, a fixed defect set, a CI recurrence gate, and durable documentation, so the class can't silently regrow the way it did between v4.37 and v4.39's own kickoff re-census.
Scope — What Shipped
- Census (Phase 1, OBJ-2671):
scripts/census-mutation-guards.mjs— deterministic detection rule, always exits 0 (report, not gate)..planning/milestones/v4.39-mutation-guard-census.md— full 40-row triaged inventory (33 original + 4 blind-spot + 3 already-excluded, now formally tagged). - Fixes (Phases 2–3, OBJ-2672/2673): 34 confirmed unguarded mutation call sites given a synchronous early-return guard, matching the house idiom at
ionic_frontend/src/composables/useGoalForm.ts:204-205. Two shared-in-flight-flag sites (useCommunity.ts,MeetCoach.vue) split into per-action refs to avoid cross-blocking unrelated actions. - Related fix (Phase 4, OBJ-2674):
useWhatsNew.ts'spersistEpoch()epoch-seed guard now holds throughacknowledgeWithRetry's full retry window via a new optionalonSettledparameter (ackRetryQueue.ts), not just its first sub-attempt — the round-1 implementation had the same re-entry gap this milestone exists to close, caught by review. - CI gate (Phase 5, OBJ-2675):
.github/workflows/mutation-guard-recurrence.ymlrunsscripts/check-mutation-guard-recurrence.mjson every PR touchingionic_frontend/src/**and on push tomaster; fails the check on any unbaselined unguarded site. 6 sites remain in the committedBASELINE, each individually reasoned (3 backend-verified idempotent, 3 script false positives). Not yet a required status check (OBJ-2708) — it fails the check today, it does not yet block merge. - Verification harness:
ionic_frontend/scripts/probes/raw-dispatch-probe.mjs— a live, real-browser (Playwright) same-tick double-dispatch probe, the verification standard this milestone's guards were checked against (a Vitest assertion thatawaits between calls cannot reproduce the failure mode). - Docs (Phase 6, OBJ-2676): new
docs/development/mutation-re-entry-guard.md; pointers fromcoding-style.md/best-practices.md; a newgotchas.md+CLAUDE.mdentry for the shared-flag cross-blocking trap; this narrative page;CHANGELOG.md. - Promotion (Phase 7): the integration branch merged to
mastervia PR #2466 (11d4c0f125a) — a redo of PR #2463, which had squash-merged in error and was reverted before it could break the promotion's ancestor-SHA/empty-diff verification.
Phases
| Phase | Name | Status | PRs | Highlights |
|---|---|---|---|---|
| 1 | Census | Shipped | 1 (#2433) | scripts/census-mutation-guards.mjs + fully-triaged inventory, replacing v4.37's unreproducible "26" figure |
| 2 | Tier-1 fixes | Shipped | 3 (#2437/#2438/#2439) | 10 create-a-record sites fixed (double dispatch = two visible rows) |
| 3 | Tier-2/3 fixes + blind-spot amendment | Shipped | 5 (#2449/#2450/#2451/#2452/#2453) | 20 remaining confirmed sites + 4 blind-spot sites fixed across 4 batches; shared-flag cross-blocking trap found and fixed |
| 4 | useWhatsNew.ts epoch-seed guard | Shipped | 1 (#2447) | Related re-entry fix; round-1 attempt reproduced the same bug class, caught in review |
| 5 | CI recurrence gate | Shipped | 1 (#2455) | mutation-guard-recurrence.yml + committed baseline of 6 individually-reasoned exceptions |
| 6 | Docs | Shipped | 1 (#2458) | Pattern doc, gotcha entry, milestone narrative |
| 7 | Promotion to master | Shipped | 1 (#2466) | Redo of squash-merged-in-error #2463, landed as a true merge commit |
Key Decisions
- A committed, re-runnable script over a one-time manual count. v4.37's "26" was a byproduct of unrelated work, unreproducible and ambiguously scoped.
scripts/census-mutation-guards.mjsis the reproducible replacement — anyone can re-run it against a clean checkout and get the same answer. - Per-action refs, never a shared flag reused across independent actions. A naive guard on a shared
loading/savingref makes unrelated actions cross-block each other. Both discovered instances (useCommunity.ts,MeetCoach.vue) were split into independent refs rather than patched with a single shared guard. - A live raw-dispatch probe, not a Vitest assertion alone, is the verification bar. A test that
awaits between two calls never reproduces a same-tick double dispatch — it always finds the guard "working."raw-dispatch-probe.mjsdrives a real browser to prove a guard survives an actual double-click, not just anawait-spaced test. - The blind spot is carved out explicitly, not silently folded into the detection rule. A handler with no manual in-flight ref at all (relying solely on Apollo's own unread
loading) is structurally invisible to the census's "sets but never reads" signature.BLIND_SPOT_STATEMENTinscripts/check-mutation-guard-recurrence.mjssays so in the gate's own output, rather than letting a clean scan read as an all-clear it can't actually claim. - The CI gate fails the check; it does not yet block merge. It's not in this repo's required-status-checks ruleset (tracked separately as OBJ-2708) — documented as a real gap rather than written up as an enforced block it isn't yet.
- A promotion PR that squash-merges by mistake is reverted and redone, not patched over. PR #2463 landed as a squash merge, which would have broken this milestone's own ancestor-SHA and empty-diff promotion criteria — the same failure mode this repo's "a milestone can finish every phase and never reach
master" gotcha exists to catch, in a subtler variant where the code lands but the audit trail doesn't. Reverted (dbdc18c48) and re-landed as PR #2466 with a true merge commit rather than accepted as close enough.
Requirements Coverage
No .planning/milestones/v4.39-REQUIREMENTS.md file exists for this milestone — coverage is derived from the ROADMAP's own "Success criteria" section (7 criteria). 6/7 fully satisfied; criterion 5 (CI fails on a new unguarded site) is partial — the gate correctly fails the check but is not yet a required status check, so it does not yet block a merge (OBJ-2708). Full criterion-by-criterion detail: MILESTONE-AUDIT.md.
Outcomes
Every confirmed unguarded mutation call site the census could find (34) has a synchronous re-entry guard; two latent shared-flag cross-blocking bugs are fixed; a CI check runs on every PR touching ionic_frontend/src and reports any new unguarded site outside the reasoned baseline (though it does not yet block merge — OBJ-2708); a live raw-dispatch probe harness exists for verifying any future guard the same way this milestone's were verified; and the pattern — plus its two easiest-to-miss traps (the :disabled-isn't-enough gap, and the shared-flag cross-blocking trap) — is documented at docs/development/mutation-re-entry-guard.md, reachable from coding-style.md, best-practices.md, and gotchas.md. The integration branch is on master (PR #2466, 11d4c0f125a), verified by content: all six phase merge SHAs confirmed ancestors, branch diff empty, check-branch-promotion.mjs contains passes, and the census/recurrence gate re-runs clean on the merged tree.
Tech Debt
- (Phase 5, OBJ-2708) The mutation-guard-recurrence CI check is not yet a required status check — a PR can merge while it's red.
- (all phases) 6 sites remain deliberately unguarded (3 backend-verified idempotent, 3 script false positives already guarded via an indirect
computedread or intentional re-entrancy) — see.planning/milestones/v4.39-mutation-guard-census.mdfor the full per-site reasoning. - (census rule) The blind spot for "no manual in-flight ref at all" is a known, permanent limitation of the current detection rule, not a defect scheduled for a fix — a future census rule change would be needed to close it. See
BLIND_SPOT_STATEMENTinscripts/check-mutation-guard-recurrence.mjs. - (carried forward, non-blocking, OBJ-2705)
Capture UI Evidence (Preview)failed on PR #2451 and never recovered — agoalsGraphQL query returnsUNAUTHORIZEDone step past smoke-account provisioning. Not a required check, so the PR merged anyway. - (carried forward, non-blocking, OBJ-2704)
check-milestone-close-branch.mjs's not-done vocabulary doesn't recognizeIN FLIGHT/🟡— this ROADMAP used🚧 IN PROGRESSinstead as a workaround rather than fixing the script mid-milestone.
Related Artifacts
- Census inventory: .planning/milestones/v4.39-mutation-guard-census.md
- Pattern doc: docs/development/mutation-re-entry-guard.md
- Census script:
scripts/census-mutation-guards.mjs - CI gate:
scripts/check-mutation-guard-recurrence.mjs,.github/workflows/mutation-guard-recurrence.yml - Verification harness:
ionic_frontend/scripts/probes/raw-dispatch-probe.mjs - Milestone audit: .planning/milestones/v4.39-mutation-re-entry-guard-sweep-MILESTONE-AUDIT.md
- Merge PR: #2466 (
11d4c0f125a) — redo of the squash-merged-in-error #2463 (reverteddbdc18c48)
Related Commits
3a8e195e4—[Codi] feat(mutation-guards): add re-entry guard census script + inventory (OBJ-2671) (#2433)3d440fb20/3468b3035/4d810596b— Tier-1 fixes (Phase 2, OBJ-2672)bb4a65d12/306e7df50/25cf617c7/3c185660c/382c0d54c— Tier-2/3 + blind-spot fixes and dispositions (Phase 3, OBJ-2673)b7263232c—[Codi] fix(whats-new): route persistEpoch() through acknowledgeWithRetry, guard epoch-seed watcher re-entry (OBJ-2674) (#2447)766b5fe98—ci(mutation-guards): wire census-mutation-guards.mjs into a CI recurrence gate (OBJ-2675, v4.39 Phase 5) (#2455)82e6d84d2— Docs (Phase 6, OBJ-2676) (#2458)11d4c0f125a— Merge pull request #2466 — promotion tomaster(Phase 7), redo of the squash-merged-in-error #2463
Last updated: 2026-08-19