Skip to content

fix(app): survive a crowded room, heal every silence, and tell no small lies - #17

Merged
frahlg merged 2 commits into
mainfrom
harden-the-wire-and-the-shell
Aug 9, 2026
Merged

fix(app): survive a crowded room, heal every silence, and tell no small lies#17
frahlg merged 2 commits into
mainfrom
harden-the-wire-and-the-shell

Conversation

@frahlg

@frahlg frahlg commented Aug 9, 2026

Copy link
Copy Markdown
Member

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

  • One stray 48-byte frame ended a handshake for good. HandshakeState mixed ck/h before 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.
  • A handshake that timed out on a healthy socket was never retried. retryable: true had 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.
  • The relay dial backoff reset on accept, not on proof. An accept-then-die crash loop was redialled at the floor interval forever. It resets on the first delivered frame.
  • Concurrent api.req met the box's one-at-a-time contract head on. Two views asking after a reconnect made the loser eat E_UNAVAILABLE busy and a 30 s backoff — including a step-up write right after Face ID. The session now queues, chained on settle.
  • Bulk deadlines measured total time, not progress. A multi-megabyte answer arriving steadily died mid-body. api.head/api.chunk/hist.chunk re-arm the deadline, so it measures silence.

The state

  • A cache restore landing mid-handshake pinned the carrier to cache for 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.
  • Pairing box B while box A streamed wrote A's readings into B's snapshot — the next cold start painted the wrong house. Repointing the store closes the session first.
  • A destroyed store accepted a carrier and streamed: sign out during a slow connect and the phone kept a live, self-reconnecting session to the house it left, unreferenced. It now closes what it is handed.
  • A failed sign-out stranded Plan, History, Energy and Access on the dead store until reload. The catch path remounts them against the restored store; the failure sentence survives via shell state.
  • The box's unasked plan pushes were received and dropped — the timeline drained to "No plan for right now" on a box that had replanned all along. The store now reads the session's copy.
  • pruneTiles wiped the year cache on every range switch, judging 1h tiles by the 5m retention. Each row is judged by its own resolution's retention.
  • History and Energy never refreshed on a wire that never dropped — "now" on an axis hours old. Their ask names carry the epoch they answer for (5-minute step and hour), so askWhenLive heals staleness the same way it heals loss.

The words

  • The hero showed "-2.00 kW" on a discharging battery and "-3.40 kW" on an exporting grid. The flow hands the vendored component magnitudes and a direction word (the vendored fmtKw keeps signs by design; upstream change filed separately).
  • The boot screen printed wire tokens ("vacuum · 40%"). It speaks sentences now.
  • "0 h not recorded" for a 20-minute gap; minutes below an hour.
  • A stream gone quiet on a healthy socket kept the particles flowing; frame silence stills the diagram, and a hidden Now no longer feeds it at 1 Hz.
  • Dead "Nothing paired yet" branch deleted; fingerprintOf duplicate replaced with the one boxFingerprint; 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 honest aria-pressed pattern History already had.

The names

  • The frozen field id table is checked against the registry's frozen_fields both ways (it was missing three).
  • Bare capability/scope literals anywhere in non-test src/ now fail the contract test; SCOPE_MODE_WRITE and CAP_PLAN_DISPATCH moved into contract.ts.

Held for paired PRs (registry is byte-locked to the box)

  • An ops block for site.mode.set / battery.hold, and client_errors homes for E_NO_ACK / E_NO_ANSWER / E_BAD_BODY.
  • Upstream fmtKw magnitude+direction in ftw-energy-flow, then a re-vendor.

npm run verify: 725 passed, 1 skipped (live-box, gated on BOX). 32 new tests, every behavioural one proven red without its fix.

🤖 Generated with Claude Code

…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 9, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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>
@frahlg
frahlg merged commit 42b2d86 into main Aug 9, 2026
3 checks passed
@frahlg
frahlg deleted the harden-the-wire-and-the-shell branch August 9, 2026 14:26

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/views/History.svelte
Comment on lines +68 to +71
askWhenLive(
untrack(() => site),
() => `${history.range} ${Math.floor(nowMs / 300_000)}`,
() => history.load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant