Silent-Drift Classes in the /ux-cycle Loop¶
📜 Historical snapshot — not current docs
Captured 2026-04-20 during Dazzle's autonomous-improvement cycles. It records the framework as it was then and the gap being worked at the time; it may not describe current behaviour. Start from the documentation home, or see Project Evolution for how these fit together.
Date: 2026-04-20 (cycle 317 framework_gap_analysis) Class: Framework loop-discipline completeness Status: Partially addressed — 4 of 6 identified classes now have automated gates; 2 remain manual-inspection-only
Problem statement¶
The /ux-cycle autonomous loop commits framework code changes ~every 10 minutes but historically ran a narrower quality gate than /ship. This mismatch allowed multiple classes of drift to accumulate silently — the cycle would pass its own self-checks while real quality regressions piled up invisibly. Cycles 311-316 surfaced 6 distinct classes of this pattern; some are now automatically gated, some still require manual inspection.
Evidence¶
| # | Drift class | Surfaced cycle | Gap duration | Detection mechanism | Status |
|---|---|---|---|---|---|
| 1 | Syrupy baseline drift — contract_audit cycles add canonical class markers to templates; test_dom_snapshots.py baselines don't regenerate |
311 | ~40 cycles (271-311) | test_dom_snapshots.py (existing, previously unrun) |
Gated (cycle 312 preflight) |
| 2 | UI type-error drift — hypothetical; no empirical accumulation observed because preflight was added preemptively | 313 (hypothesised) / 314 (gated) | 0 cycles | mypy src/dazzle_page/ |
Gated (cycle 314 preflight) |
| 3 | Dist/ build artifact drift — source changes to CSS/JS don't propagate to dist/ until someone manually rebuilds |
313 | ≥20 cycles (297 → 313 footer CSS var) | git status (manual) |
Manual — no automated gate |
| 4 | Canonical-helper bypass — file-local helper wraps raw responses for static-analysis discrimination; some call sites use raw constructor instead | 315 | Unknown (likely years) | Grep-style audit (manual) | Manual — one-time cycle fix, no lint |
| 5 | DaisyUI tokens in Python-embedded HTML — cycle 17 swept templates; Python string-literal HTML wasn't in scope | 316 | ~300 cycles (17 → 316) | Grep for DaisyUI class names (manual) | Manual — one-time cycle fix, no lint |
| 6 | Snapshot-regeneration discipline in contract_audit strategy — the strategy spec mentions "regression tests matching each quality gate" but doesn't explicitly require syrupy baseline refresh | 311 → 312 (inferred) | ~14 cycles (271-285) | Skill spec review (process fix) | Gated (cycle 312 preflight catches downstream manifestation) |
Related observations from cycles 300-309 that reinforce the theme:
- Cycle 300 external-resource-integrity — CDN loads without SRI hashes. Silent trust drift; manually surfaced.
- Cycle 305 template-ship-without-wiring — HTML page templates ship without server routes. Eventually gated by cycle 306-308's test_page_route_coverage.py.
- Cycle 309 missing_contracts retrospective — manual breadth scans for uncontracted components superseded by continuous lints.
Across 20+ cycles, the pattern is consistent: a class of drift accumulates invisibly until a full test suite run or manual audit surfaces it, then one cycle either (a) adds a gate or (b) manually cleans up.
Root cause hypothesis¶
Three compounding factors:
1. Scope asymmetry between /ux-cycle and /ship¶
The /ship skill runs ruff check, ruff format, and mypy on the entire relevant framework subtree before every push. The /ux-cycle skill commits but doesn't push — and (pre-cycle-312) ran no pytest or mypy at all. The cron-fired loop could ship framework code through git for hours before anyone invoked /ship, by which point the damage was done.
Cycle 312's preflight gate closed the largest leak (lints + snapshots + card-safety), and cycle 314 added mypy(dazzle_page). But the gate is deliberately scoped to <10s to not balloon the 10-minute cron cadence, which means:
- Broader mypy (dazzle_http, core, cli, mcp) is not in the gate
- git status / dist/ rebuild isn't checked
- Linting of Python string literals for embedded HTML class names isn't checked
2. Manual sweeps have naming-pattern limitations¶
Cycle 17's "DaisyUI token sweep" (EX-001) closed at 62 template files. Cycle 316 found 6 more sites in Python string literals. The cycle 17 sweep matched **/*.html — it was implicitly scoped. Same pattern for cycle 302's test_template_orphan_scan.py — it only finds orphans under src/dazzle_page/templates/, not equivalent orphan Python modules.
When a sweep's scope is file-extension-based, drift accumulates in adjacent file types using the same pattern.
3. Contract_audit cycles don't have a discipline checklist¶
The contract_audit strategy playbook says (step e) "regression tests matching each quality gate" but doesn't enumerate: does that include snapshot refresh? Mypy check? Grep for DaisyUI tokens? As a result, each contract_audit cycle's hygiene depended on the runner's judgment — and cycles 271-284 consistently forgot to refresh syrupy baselines.
Fix sketch¶
Two-axis strategy:
Axis A — Close remaining gates¶
A1: Add broader mypy to a separate, slower gate.
- Create make test-ux-deep target running mypy src/dazzle/core src/dazzle/cli src/dazzle/mcp src/dazzle_http/runtime (~13s total). Not part of preflight (keeps cron-cadence tight) but runnable manually or as a pre-commit hook for cycles that touch framework Python.
- Alternative: add dazzle_http/runtime to preflight, accept ~13s gate. Trade-off: 4s extra per cycle = ~24s/hour = 10 minutes/day. Probably worth it given cron cadence is 10 minutes.
A2: Add dist/ drift detection.
- Git-status check as preflight's last step: if dist/ has uncommitted changes, log a warning (non-blocking, to avoid blocking cycles that happen while a build is in progress).
- Or: require make build to complete cleanly; this is what /ship implicitly assumes.
A3: Extend DaisyUI-token lint beyond templates.
- New horizontal-discipline lint: grep Python source for DaisyUI token patterns (text-error, alert alert-*, btn-primary, etc.). The allowlist handles legitimate uses (dispatch tables, fallback HTML).
- Adds 1 file, ~80 LOC, <0.5s to preflight.
Axis B — Codify contract_audit hygiene¶
B1: Contract_audit checklist.
Edit the /ux-cycle skill's contract_audit strategy description to explicitly list:
After template edits in contract_audit cycles: 1. Run
make test-ux-preflight— this catches snapshot drift 2. If snapshots fail, verify diff is additive-only, thenpytest tests/unit/test_dom_snapshots.py --snapshot-update3. Grep for DaisyUI tokens in the touched files:grep -E "(text-error|btn-primary|alert-)" <touched-files>4. Verify canonical class markers are added (the cycle's stated goal)
This turns implicit discipline into explicit checklist — reduces the cost of "remembering the right hygiene" on each audit cycle.
B2: Add a /contract_audit subcommand.
Extract the recurring audit discipline into a named skill at .claude/commands/contract_audit.md. Invoked as /contract_audit <component> with the component name. The skill embodies the checklist above. Makes the workflow more reproducible than informal cycles.
Blast radius¶
- Classes 1 + 2 + 6 already gated — zero ongoing exposure.
- Class 3 (dist/) — affects runtime CSS/JS bundle shipped in wheels. Impact: users installing the dist wheel get an older CSS/JS. Repro: cycle 313 found 20+ cycles of drift. Severity: cosmetic for now (just a version header + one CSS variable), but could become functional if a JS change doesn't propagate.
- Class 4 (canonical-helper bypass) — narrow impact, only 5 sites in one file. Security story intact (already-escaped inputs). Ongoing risk: new framework files could introduce the same drift — no automated lint detects it.
- Class 5 (DaisyUI in Python strings) — 6 sites fixed in cycle 316, 3 intentionally deferred (dispatch table + dev fallback + data mapping). Ongoing risk: new Python code embedding HTML class names could reintroduce DaisyUI tokens — no automated lint detects it.
Affected apps: all 5 example apps load dist/dazzle.min.css for runtime styling, so dist/ drift reaches every app in practice.
Open questions¶
-
Is the
/ux-cyclepreflight gate the right escalation path? The cycle 312 addition fits naturally (runs on every cycle), but the trade-off is latency: every added check eats into the 10-minute cron cadence. At what point does the gate's cost exceed its value? Cycle 314 already decided 9s was acceptable; cycle 317's recommendation to add dazzle_http/runtime mypy would push to ~13s. Is there a "gate cycle" vs "fast cycle" distinction worth introducing? -
Should
test_canonical_pointer_lint.pygrow to cover Python-embedded HTML? The cycle 310 lint is template-scoped. An analogous Python lint that checks "inline HTML strings in Python should use canonical helpers + canonical tokens" would catch classes 4 + 5 automatically. Scope estimate: ~100 LOC, AST walk for string literals starting with<(roughly). -
Is cycle 309's "retire missing_contracts" call still right? It concluded the strategy was superseded because 3 lints now run continuously. But cycle 316 found DaisyUI drift that none of those lints would catch. Maybe a narrower breadth-scan strategy — "drift sweep" — should replace
missing_contractsrather than nothing replacing it. -
Is Heuristic 1 enough to prevent this class of accumulation? Heuristic 1 is "try the real thing at raw layer before writing framework code." It's aimed at INVESTIGATION cycles. The silent-drift cases here weren't investigations — they were routine IMPLEMENTATION cycles (contract_audit adds class markers) that forgot a hygiene step. Maybe Heuristic 5: "after template/Python edits, run
make test-ux-preflightbefore commit." Candidate for promotion. -
Can we teach the gate the concept of "relevant"? Cycle 314's mypy runs on ALL 54 files of dazzle_page regardless of whether this cycle touched them. Incremental mypy could restrict to changed files. Faster gate, same coverage. But mypy's module-graph resolution makes "changed files" nonobvious — imports from unchanged files still matter. Practical budget: probably not worth the engineering cost right now.
Recommendation¶
Ship Axis A1 + A3 this cycle or next. Both are concrete, small (<200 LOC each), and close remaining classes without major process changes.
Defer Axis B (contract_audit checklist / skill). The hygiene gap existed in cycles 271-284 but cycle 312's preflight now catches the downstream effect automatically. Writing a new skill or editing skill docs has lower marginal value than it did pre-cycle-312.
Elevate the meta-pattern in /ux-cycle durable heuristics. Add Heuristic 5:
After any cycle that edits template files, Python files emitting HTML, or CSS, run
make test-ux-preflight. Do not commit if red.
This turns the existing preflight gate (currently a Step 0a of next cycle) into an outbound gate of each cycle too — drift is caught on introduction, not one cron tick later.
Status tracking¶
| Class | Status | Next step |
|---|---|---|
| 1 — syrupy baselines | GATED | — |
| 2 — UI type errors | GATED | — |
| 3 — dist/ drift | MANUAL | Axis A2 candidate |
| 4 — canonical-helper bypass | MANUAL | Axis A3 partial coverage |
| 5 — DaisyUI in Python HTML | MANUAL | Axis A3 candidate |
| 6 — contract_audit hygiene | GATED (downstream) | Axis B optional |
Cross-refs:
- cycle 311 log — snapshot debt discovery + cleanup
- cycle 312 log — test-ux-preflight shipped
- cycle 313 log — dist/ drift flagged; 4 flavours of silent drift enumerated
- cycle 314 log — mypy(dazzle_page) added to gate
- cycle 315 log — helper_audit on HTMLResponse bypass
- cycle 316 log — DaisyUI Python sweep
- tests/unit/test_dom_snapshots.py — the syrupy catcher
- Makefile test-ux-preflight target — the gate itself