fix(app): survive a crowded room, heal every silence, and tell no small lies - #17
Conversation
…ll lies
A review round with adversarial verification behind every change; each fix
carries the test that failed without it.
The wire:
- A stray 48-byte frame poisoned an in-flight handshake: state mixed before
the tag check. Both read paths now commit on success, so the genuine
reply still authenticates after a stranger's — which is what a relay
that broadcasts to the whole room requires.
- A handshake that timed out on a healthy socket was never retried; one
silent miss left the app down until the hourly epoch rotation. The
carrier now retries with jittered backoff while the socket stands.
- The relay dial backoff reset on accept, so an accept-then-die relay was
redialled at the floor forever. It resets on a delivered frame.
- api.req is serialized to match the box's one-at-a-time contract, so two
views asking at once no longer burn a 30 s backoff on E_UNAVAILABLE.
- Bulk deadlines now measure quiet time, not total time: a large answer
arriving steadily can take as long as it takes.
The state:
- A cache restore landing mid-handshake pinned carrier to 'cache' for the
life of the connection — "Can't reach your box" over a live stream.
Restore now applies only data while a carrier is driving, and a snap
restates the carrier feeding it.
- Pairing a second box while the first streamed wrote house A's readings
into box B's cache. Repointing the store now closes the session first.
- A destroyed store accepted a carrier and streamed — a live session to a
house the phone had left. It now closes what it is handed.
- A failed sign-out stranded every view but Now on the dead store; the
catch path now remounts them against the restored one.
- The box's unasked plan pushes were received and dropped; the store now
reads the session's copy, so a replan reaches the screen.
- pruneTiles judged every resolution by the one just served, wiping the
year cache on each range switch. Each row is judged by its own retention.
- History and Energy never re-asked on a wire that never dropped; their ask
names now carry the epoch they answer for.
The words:
- The Now hero showed "-2.00 kW" on a discharging battery; the flow now
hands magnitudes and a direction word.
- The boot screen printed wire tokens ("vacuum · 40%"); it speaks now.
- "0 h not recorded" under 30 minutes; minutes below an hour.
- A quiet stream kept the flow animating; frame silence now stills it, and
a hidden Now no longer feeds the diagram at 1 Hz.
The names:
- Frozen field ids are checked against the registry both ways; bare
capability and scope literals anywhere in src/ fail the contract test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
ftw-webapp | 320e4de | Aug 09 2026, 02:24 PM |
Lowercasing the whole "Then …" clause dragged the unit down with it — "covers the house at 170 w". The later wording already starts lowercase, so the call did nothing but that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa48e2fe00
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| askWhenLive( | ||
| untrack(() => site), | ||
| () => `${history.range} ${Math.floor(nowMs / 300_000)}`, | ||
| () => history.load() |
There was a problem hiding this comment.
Stop polling history while its tab is hidden
After History has been visited, App.svelte keeps the component mounted and only hides its container when another tab is selected (lines 413–419). This new key therefore keeps changing every five minutes and calling history.load() while the user is on Now, Plan, or Box, causing recurring bulk requests and IndexedDB work for the rest of a long-lived session; the 1-year range is even refreshed twelve times per hour despite using hourly data. Pass the route's active state into this component and suspend periodic refreshes while it is hidden, as the change already does for Now.
Useful? React with 👍 / 👎.
A full review of the webapp against CLAUDE.md's invariants, run as three rounds: four reviewers over the layers, ten adversarial verifiers ordered to refute what they found (10 of 10 findings survived, each with a running reproduction), then fixes grouped so every change carries the test that failed without it.
The wire
HandshakeStatemixedck/hbefore the tag check, so after a frame that failed to authenticate, the genuine message 2 could never authenticate either. The relay broadcasts every frame to the whole room and message 2 is exactly 48 bytes — two phones connecting at once poisoned each other. Both read paths now run against a clone and commit on success. Reproduced first: control handshake passes, stray-then-genuine failed.retryable: truehad no consumer; one silent miss left the app on "failed" until the hourly epoch rotation. The carrier now retries with full-jitter backoff (3 s base, 60 s cap) while the inner socket stands; a revoked phone costs one 48-byte message a minute at worst.api.reqmet the box's one-at-a-time contract head on. Two views asking after a reconnect made the loser eatE_UNAVAILABLE busyand a 30 s backoff — including a step-up write right after Face ID. The session now queues, chained on settle.api.head/api.chunk/hist.chunkre-arm the deadline, so it measures silence.The state
cachefor the life of the connection — the band read "Can't reach your box" over a live stream. Restore applies only data while a carrier is driving (and never overwrites live fields), and a snap restates the carrier feeding it. The cold-start paint survives a connect that outruns the cache and then dies.pruneTileswiped the year cache on every range switch, judging 1h tiles by the 5m retention. Each row is judged by its own resolution's retention.askWhenLiveheals staleness the same way it heals loss.The words
fmtKwkeeps signs by design; upstream change filed separately).fingerprintOfduplicate replaced with the oneboxFingerprint; chart fallbacks no longer hard-code the dark palette; the install hint's button answers to the word printed on it; every deferred chunk has a{:catch}sentence; Energy/Plan choice rows use the honestaria-pressedpattern History already had.The names
frozen_fieldsboth ways (it was missing three).src/now fail the contract test;SCOPE_MODE_WRITEandCAP_PLAN_DISPATCHmoved intocontract.ts.Held for paired PRs (registry is byte-locked to the box)
opsblock forsite.mode.set/battery.hold, andclient_errorshomes forE_NO_ACK/E_NO_ANSWER/E_BAD_BODY.fmtKwmagnitude+direction inftw-energy-flow, then a re-vendor.npm run verify: 725 passed, 1 skipped (live-box, gated onBOX). 32 new tests, every behavioural one proven red without its fix.🤖 Generated with Claude Code