You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Reviewed issue #674 (plan for source #673) for correctness / performance / architecture / testing / cloud ops / living docs / layers / UI / cap governance.
Issue
Severity
Resolution
Goal 3 (spinner 8-cycle across 255→256) had no unit row
Minor
Locked busySpinnerCells(256) == cells(0) and one-step 255→256 in test-rich
Baseline “starts at 1 while Busy” skipped the existing 100 ms phase-0 push
Minor
Host tick starts 0; busy-start + reduced-motion push 0; interval does tick += 1 then setBusyTick(tick) (first animated value is 1). Do not change that 100 ms solid flash
u8 truncate on the comet path would reintroduce the hitch
Minor
text_wave / headPosition must see the full u32. Spinner may % 8 only
No Blockers. No existing-cap change (the 1..255 fold is a lossy map, not a transport budget).
The comet head in text_wave.zig teleports ~7 glyphs every 25.6 s because inv_set_busy_tickthrows away the host's monotonic u32 and stores (phase-1)%255+1. The host already sends 1, 2, … 256, 257. Keep that counter. No protocol bump, no host change.
Goals
#
Goal
Success signal
1
Adjacent host ticks advance the comet by one SPEED step (~⅔ glyph), including across the old 255→256 boundary
headPosition(256, 18) − headPosition(255, 18) ≈ 0.667; the #671 pin that 255 vs 1 jumps > 5 no longer describes paint
2
Phase 0 still means idle / reduced motion / old host — solid ramp[0], no mid-turn flash
Host setBusyTick(0) and reduced-motion (no ticks) still paint a static accent line
3
Spinner stays a smooth 8-step clockwise loop across the same boundary
busySpinnerCells(256) equals cells(0); 255 % 8 == 7 then 256 % 8 == 0 — no skipped cell
Forbidden wiring: protocol bump for a value the wire already carries · dual DOM chat · secrets in Wasm · laptop-only ops · @truncate(u8, phase) (or equivalent) on the text_wave / headPosition path
Architectural decisions
Decision
Options considered
Choice
Why
Where to fix
A) Wider wire (u16/u32 export + protocol bump) · B) Wasm wrap-counter on the folded u8 · C) Stop folding — store the host u32 · D) Accept hitch
C
Host HarnessHost already increments a monotonic tick and calls setBusyTick(tick). The 255→1 jump is invented in inv_set_busy_tick. Restoring the bits is one assignment, no bump, no wrap-detect heuristics
0 sentinel
A) Keep 0 = idle/reduced-motion · B) Use a side flag
A
text_wave phase-0 fast-path and old-host (busy_tick stays 0) already depend on it. Host sends 0 on Ready/Stop/error and at busy-start; busy interval ticks start at 1
Phase type through paint
A) Keep u8 at paint, accumulate elsewhere · B) u32 from bridge → paintBusyRow / text_wave / rect_spinner
B
One type. Spinner still % 8. Avoids a second folded copy that can drift. Never truncate to u8 before headPosition
Multiply width
A) u32phase * SPEED · B) u64
B
SPEED=2 overflows u32 at phase > 2^31 (~6.8 y at 10 Hz). Cheap; matches the existing “don't wrap the multiply” lesson from #671
tick local starts at 0. Busy-start and reduced-motion push setBusyTick(0). Interval: tick += 1 then setBusyTick(tick) → 1, 2, … 256. First ~100 ms of a turn is solid (phase 0) — existing, out of scope
Export already takes u32
bridge.zig:733inv_set_busy_tick(phase: u32)
Folds to u8: (phase-1)%255+1 — this is the bug
Stored width
bridge.zig:113var busy_tick: u8
Must widen
Reader
bridge.zig:368pub fn busyTick() u8
Callers: ui.zig:354 busy row, ui.zig:587 status spinner. No JS getter export
Comet head
text_wave.zigheadPosition(phase: u8, N)
raw = phase * SPEED (already u32 mul); raw % (N*STEPS)
Teleport pin
text_wave.test.zig255→1 wrap teleport
head(255,18)=8.0, head(1,18)≈0.667, delta>5 — documents the folded inputs, not the host sequence
Do not keep delta(255, 1) > 5 as a live paint contract — that pair is not on the host sequence.
Docs
In docs/harness-limits.md Busy spinner row, replace the live claim that the bridge “maps busy ticks to 1..255 (wrapping 255→1)” with: host monotonic u32 is stored as-is; 0 is reserved for idle / reduced motion / busy-start; the comet wraps only on N*STEPS (visible loop ~2.7 s), not on a 25.6 s u8 fold. Timeless — no issue numbers.
Cloud ops path
N/A — no Production mutate. Zig on the self-hosted build-harness runner (existing artifact → Vercel).
Living docs plan
Surface
Change
Notes
docs/harness-limits.md
Busy spinner wrap sentence (see Design)
Timeless; no issue/phase theater
AGENTS.md
N/A — protocol stays v17; no 255-wrap claim to update
README.md
N/A — visitor entry unchanged
SECURITY.md
N/A — no secrets / trust-boundary change
.env.example
N/A — no env
docs/feature-divide.md
N/A — spinner ownership row has no 255-wrap claim
Implementation order
text_wave.zig — phase: u32, u64 multiply; rewrite the wrap test
Minimum locked for DoD: 1–8, 11, plus operator 9–10.
Caps table
Cap / ceiling
Value
Rationale
Code location
HARNESS_BUSY_TICK_HZ
10 (unchanged)
Existing host cadence. This plan does not raise or lower it
HarnessHost.tsx
Wire inv_set_busy_tick
u32 (already)
Host tick is already this width. Not a change
bridge.zig export
Idle sentinel
0 (unchanged)
Reduced motion / old host / Stop / busy-start
text_wave phase-0 fast-path
No existing cap is raised or lowered. The 1..255 fold is an internal lossy map, not a transport budget — it is removed, not replaced with a new named cap. The comet’s only wrap is N * STEPS (period 54 at N=18), already locked.
Definition of done
Adjacent host ticks (including 255→256) move the comet by one SPEED step; no ≥5-glyph jump
Phase 0 still solid accent (idle / reduced motion / old host / busy-start)
Spinner 8-cycle is continuous across the same boundary (256 ≡ 0)
Protocol remains v17 — no export / REQUIRED_FNS change
text_wave + busy_spinner tests green (zig build test-rich); #671 teleport pin replaced by the continuity cases
npm test + npx tsc --noEmit green
build-harness green
docs/harness-limits.md Busy spinner row describes monotonic u32 + 0 sentinel (no “wraps 255→1” as live behavior)
Cloud ops: N/A
No dual-chat; no existing-cap change; no u8 truncate on the comet path
Risks & mitigations
Risk
Mitigation
Something else assumed busyTick() is u8
Zig will fail the type at compile; grep busyTick / .phase after the widen
Layout tests pass a u8 literal into paintBusyRow
Coerces to u32 in Zig — no change needed
Host mock tests stored the raw argument and never saw the fold
They stay green; the fold lived only in Wasm
Implementer @truncates to u8 “to match Options”
Forbidden above; comet tests 256 vs 255 fail if they do
Review notes (2026-08-18)
Reviewed issue
#674(plan for source #673) for correctness / performance / architecture / testing / cloud ops / living docs / layers / UI / cap governance.busySpinnerCells(256) == cells(0)and one-step 255→256 intest-richtickstarts 0; busy-start + reduced-motion push0; interval doestick += 1thensetBusyTick(tick)(first animated value is 1). Do not change that 100 ms solid flashu8truncate on the comet path would reintroduce the hitchtext_wave/headPositionmust see the fullu32. Spinner may% 8onlyNo Blockers. No existing-cap change (the 1..255 fold is a lossy map, not a transport budget).
Status: HANDOFF-READY
Reviewed: 2026-08-18 (correctness / performance / architecture / testing / cloud ops N/A / living docs / layers / UI / caps)
Plan header
plan/busy-tick-comet-wrapdocs/harness-limits.md(Busy spinner wrap sentence)Summary
The comet head in
text_wave.zigteleports ~7 glyphs every 25.6 s becauseinv_set_busy_tickthrows away the host's monotonicu32and stores(phase-1)%255+1. The host already sends 1, 2, … 256, 257. Keep that counter. No protocol bump, no host change.Goals
headPosition(256, 18) − headPosition(255, 18) ≈ 0.667; the#671pin that255vs1jumps> 5no longer describes paint0still means idle / reduced motion / old host — solidramp[0], no mid-turn flashsetBusyTick(0)and reduced-motion (no ticks) still paint a static accent linebusySpinnerCells(256)equalscells(0);255 % 8 == 7then256 % 8 == 0— no skipped cellinv_set_busy_tick(u32)alreadyNon-goals / out of scope
inv_set_busy_tickis alreadyu32)HARNESS_BUSY_TICK_HZ(stays 10)@truncate(u8, phase)(or equivalent) on thetext_wave/headPositionpathArchitectural decisions
u32· D) Accept hitchHarnessHostalready increments a monotonictickand callssetBusyTick(tick). The 255→1 jump is invented ininv_set_busy_tick. Restoring the bits is one assignment, no bump, no wrap-detect heuristics0sentineltext_wavephase-0 fast-path and old-host (busy_tickstays 0) already depend on it. Host sends 0 on Ready/Stop/error and at busy-start; busy interval ticks start at 1u8at paint, accumulate elsewhere · B)u32from bridge →paintBusyRow/text_wave/rect_spinner% 8. Avoids a second folded copy that can drift. Never truncate tou8beforeheadPositionu32phase * SPEED· B)u64SPEED=2overflowsu32atphase > 2^31(~6.8 y at 10 Hz). Cheap; matches the existing “don't wrap the multiply” lesson from #671Layer placement
native/harness/src/bridge.ziginv_set_busy_tick/busyTick()u32; drop the% 255fold;0still stored as0text_wave.zigheadPosition/Options.phaseu32;% (N*STEPS)is the only wrap the comet needsbusy_row.zig,rect_spinner.zig,busy_spinner.zigu32phase; spinner LUT still mods 8app/harness/HarnessHost.tsxdocs/harness-limits.mdBusy spinner rowCurrent baseline (live code)
main@ea1ff52(PR #671 merged). Protocol v17.app/harness/HarnessHost.tsx~752–781ticklocal starts at 0. Busy-start and reduced-motion pushsetBusyTick(0). Interval:tick += 1thensetBusyTick(tick)→ 1, 2, … 256. First ~100 ms of a turn is solid (phase 0) — existing, out of scopeu32bridge.zig:733inv_set_busy_tick(phase: u32)(phase-1)%255+1— this is the bugbridge.zig:113var busy_tick: u8bridge.zig:368pub fn busyTick() u8ui.zig:354busy row,ui.zig:587status spinner. No JS getter exporttext_wave.zigheadPosition(phase: u8, N)raw = phase * SPEED(already u32 mul);raw % (N*STEPS)text_wave.test.zig255→1 wrap teleporthead(255,18)=8.0,head(1,18)≈0.667,delta>5— documents the folded inputs, not the host sequencetext_wave.paintphase==0→ oneaddTextatramp[0]busy_spinner.busySpinnerCells(phase: u8)phase % 8. Existing test:255 == 7; add256 == 0busy_row_layout.test.zigpaintBusyRow(0, 0)/(7, 60)u32PROTOCOL_VERSION = 17harness-limits.md)Math of the folded hitch (N=18, STEPS=3, SPEED=2, period=54):
raw512 % 54 = 26,26/3 ≈ 8.667. Step from 8.000 is2/3— one SPEED increment.Design
inv_set_busy_tickHost already
Math.max(0, Math.floor(phase)) | 0, so negatives never arrive. No second remap.inv_init/ clear paths that setbusy_tick = 0stay.text_waveOptions.phase: u32headPosition(phase: u32, N: usize) f32— multiply in u64:raw = @as(u64, phase) * @as(u64, SPEED); return (raw % period) / STEPS0fast-path unchanged@truncate/@intCasttou8before this functionPaint plumbing
paintBusyRow(phase: u32, …),rect_spinner.Options.phase: u32,busySpinnerCells(phase: u32)—% 8as today.ui.zigalready passesbridge.busyTick()through.Tests (replace the #671 pin)
The old test pins the folded pair
(255, 1). Paint will no longer see that pair. Replace it:headPosition(256, 18) − headPosition(255, 18) ≈ 2/3headPosition(8, 18) − headPosition(7, 18) ≈ 2/3headPositionatraw % 54 == 0→0.0(e.g. phase 27 on N=18)headPosition(0, 18) == 0(fast-path domain; paint does not wave)headPosition(1 << 31, 18)is not0(u32phase*2would overflow to 0); expect≈ 7.333(2^32 % 54 = 22,/3)busySpinnerCells(256)equalscells(0);cells(255)equalscells(7)(already pinned)Keep the occupancy / trail / UTF-8 tests as-is.
Do not keep
delta(255, 1) > 5as a live paint contract — that pair is not on the host sequence.Docs
In
docs/harness-limits.mdBusy spinner row, replace the live claim that the bridge “maps busy ticks to 1..255 (wrapping 255→1)” with: host monotonicu32is stored as-is;0is reserved for idle / reduced motion / busy-start; the comet wraps only onN*STEPS(visible loop ~2.7 s), not on a 25.6 s u8 fold. Timeless — no issue numbers.Cloud ops path
N/A — no Production mutate. Zig on the self-hosted
build-harnessrunner (existing artifact → Vercel).Living docs plan
docs/harness-limits.mdAGENTS.mdREADME.mdSECURITY.md.env.exampledocs/feature-divide.mdImplementation order
text_wave.zig—phase: u32, u64 multiply; rewrite the wrap testtext_wave.test.zig— continuity + large-phase casesbridge.zig—busy_tick: u32, drop% 255fold,busyTick() u32busy_row.zig/rect_spinner.zig/busy_spinner.zig— phaseu32busy_spinner.test.zig—256 == 0(keep255 == 7)docs/harness-limits.md— wrap sentencebuild.zigtest-richstill registerstext_wave+busy_spinner(no new module)Testing
headPosition(256, 18) − headPosition(255, 18) ≈ 2/3zig build test-rich7 → 8on N=18 is also≈ 2/3zig build test-richzig build test-richzig build test-richheadPosition(1<<31, 18) ≈ 7.333(u64 mul; not u32-wrap 0)zig build test-richzig build test-richbusySpinnerCells(256) == cells(0);cells(255) == cells(7)zig build test-richsetBusyTickstill floors / clamps / accepts 0 (existing TS)npm testnpx tsc --noEmit+build-harnessMinimum locked for DoD: 1–8, 11, plus operator 9–10.
Caps table
HARNESS_BUSY_TICK_HZHarnessHost.tsxinv_set_busy_ticku32(already)bridge.zigexport0(unchanged)text_wavephase-0 fast-pathNo existing cap is raised or lowered. The 1..255 fold is an internal lossy map, not a transport budget — it is removed, not replaced with a new named cap. The comet’s only wrap is
N * STEPS(period 54 at N=18), already locked.Definition of done
256 ≡ 0)text_wave+busy_spinnertests green (zig build test-rich);#671teleport pin replaced by the continuity casesnpm test+npx tsc --noEmitgreenbuild-harnessgreendocs/harness-limits.mdBusy spinner row describes monotonic u32 + 0 sentinel (no “wraps 255→1” as live behavior)u8truncate on the comet pathRisks & mitigations
busyTick()isu8busyTick/.phaseafter the widenu8literal intopaintBusyRowu32in Zig — no change needed@truncates tou8“to match Options”u32tick ~13 y)SPEED=2doublingOpen questions
None — in-scope choices are locked.
References
inv_set_busy_tick(#574/#655)docs/harness-limits.md— Busy spinner row