Skip to content

feat(app): phones read their home over Noise at /v1/app (C3) - #5

Closed
frahlg wants to merge 5 commits into
mainfrom
claude/cloud-c3-727661
Closed

frahlg wants to merge 5 commits into
mainfrom
claude/cloud-c3-727661

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

FTW Cloud C3, the service's part: phones read their home at /v1/app. Draft.

This is the second half of a wire pair. The first is srcfl/ftw srcfl/ftw#1603, and go.mod moves go/protocol to that branch at c6301a0b. The app is srcfl/ftw-app srcfl/ftw-app#19.

What changes

internal/appsession, /v1/app.

  • The phone opens wss://…/v1/app?home=<id> and runs Noise IK as initiator to the service's Noise key, with ftw.cloud.v1:<home> as prologue.
  • The service admits it only through store.For with the key the handshake proved. A phone gets in only if the box listed it in a signed roster and no owner has suspended it.
  • Every refusal closes before message 2, with 4403.
  • Message 1 carries now {hz}. Message 2 carries the snapshot from memory: the newest envelope of each class the device may read, the newest live with sources, and the newest hb, in rising seq.
  • After message 2, each new box envelope the device may read follows on the same socket, unchanged: one unpadded lane 1 frame per Noise transport message.
  • Snapshot and subscription are taken under one lock, so nothing falls between them.

Watch. While at least one phone looks, the service sends the box watch {hz: 1} at once and again every 50 s, inside the box's 60 s. When nobody looks it stops. A box that reconnects while a phone looks is asked at once.

Owner pause. An owner may send suspend or unsuspend for a device in the roster. It only narrows access:

  • A viewer's request is ignored.
  • A device outside the roster cannot be named.
  • Lifting a suspension gives back no more than the roster allows.
  • A suspension ends when the roster drops the device or its epoch moves.
  • The registry keeps suspensions; a purge deletes them.

Roster changes close sessions. The service closes a session at once when:

  • the device leaves the roster: 4403;
  • its epoch, role or effective scopes change: 4409;
  • an owner suspends it: 4403;
  • the roster expires: 4403, at upkeep;
  • the home is purged: 4403.

Limits per home.

  • 16 sessions.
  • One session per device; a newer one replaces the older (4408).
  • 256 envelopes queued per phone; a phone that falls behind is closed and gets a fresh snapshot later.
  • 4 KiB per phone message.
  • 20 messages in a burst, then 2 a second.
  • A 10 s handshake.
  • The same ping and deadlines as the uplink.

Optional Noise key. ftwcloudd keygen -noise -out noise.key prints the public key for the app; -noise-key loads it. Without the key file, the service still serves boxes and /v1/app answers 501. A deploy before the deploy files learn the second secret therefore cannot stop the service. The deploy files are unchanged here because #3 owns them. The Noise key goes beside the service key once it lands.

Tests

  • go vet ./... && go test -race ./..., go test -tags ftwclouddev ./... and bash scripts/check-imports.sh all pass. Only go/protocol is imported from srcfl/ftw.
  • internal/appsession drives a Go phone over real TLS 1.3 and WebSocket. It covers:
    • the snapshot, then the stream, carrying the box's bytes;
    • a viewer's scope;
    • four kinds of refusal, all saying nothing;
    • a revoke closing within a second;
    • narrower scopes and a new epoch;
    • a purge;
    • suspension only narrowing;
    • watch while looking;
    • a box that comes back;
    • a replaced session;
    • a busy home;
    • a snapshot that spills into frames;
    • a flood;
    • a tampered message.
  • FuzzPhoneMessage and FuzzNow ran 20 s and 10 s locally.
  • TestTheSnapshotArrivesFast measures TCP, TLS 1.3, the upgrade, Noise IK and a five-envelope snapshot on loopback. Over 20 runs: p50 1.7 ms, p95 3.0 ms, max 3.0 ms.
  • BenchmarkSnapshot: about 225 µs per phone for both Noise ends, the store and the payload, with no network. Message 2 is 2,514 B.

End to end, locally

The setup was a Core from srcfl/ftw#1603, this ftwcloudd, a simulated house and the app from srcfl/ftw-app#19. The laptop was on a phone's hotspot and the relay was production. Each measurement ran five times on new sockets.

What Times (ms) Median
First frame, from the phone's cache 0, 0, 0, 0, 1 0
FTW Cloud snapshot (layer 2) 6, 10, 8, 11, 13 10
First FTW Cloud update after it (layer 3) 874, 3737, 3492, 4715, 3190 3,492
Relay, first reading (today's path) 405, 404, 399, 404, 402 404
  • The cloud ran on loopback. cloud.ftw.energy runs main (C2) without /v1/app, and C3 is not deployed, so the C3 test did not run against it.
  • The real network adds about four round trips. From the same laptop, cloud.ftw.energy answered TLS in 70–132 ms and /healthz in 107–168 ms. That puts layer 2 near 150 ms over mobile data.
  • Layer 3 is the house, not the service. The cloud sends on change, at most once a second while watched. In a 20 s probe the simulated house changed at gaps of 1.0, 3.5 and 4.5 s. The relay's lane 0 ticks at a fixed 1 Hz.
  • A replayed snapshot goes stale. The real snapshot bytes, read an hour late, grade all sources down and the fields down or stale.
  • A revoke closes the stream. DELETE /api/app-link/devices/{id} on the box closed that phone's cloud stream 3 and 12 ms after the request, with 4403.
  • The cloud closing falls back to the relay. Stopping ftwcloudd under a running home moved the carrier to relay within 11 ms. It came back in 11.8 s, because the phone was refused once while the box reconnected and then asked again.

Follow-ups, not in this PR

  • A restarted service refuses phones until each box reconnects. Seeding admission from the registry's signed rosters at start would let a phone in at once, with an empty snapshot.
  • C4's session (claude/cloud-c4-eb03d7) plans push.register and push.unregister on this socket. phoneConn.apply is the place: two more cases.

🤖 Generated with Claude Code

frahlg and others added 5 commits October 13, 2026 07:50
A phone runs Noise IK to the service's Noise key with the home id in the
prologue. The service admits it only through store.For with the key the
handshake proved, so only a phone in the box's signed roster that no
owner has suspended gets in. Message 2 carries the snapshot from memory;
after it each new box envelope the device may read follows on the same
socket, unchanged.

A roster change, a suspension, an expired roster or a purge closes the
sessions it touches at once. While a phone looks, the service asks the
box for one-second updates and renews the ask every 50 s. Limits per
home: 16 sessions, one per device, a queue of 256 per phone and a small
message rate. Owners' suspensions live in the registry and end when the
roster drops the device or its epoch moves.

go/protocol moves to srcfl/ftw claude/cloud-c3-core (7490c64c) for the
app session wire, the Noise initiator and RemoteStatic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/v1/app answers 501 until a Noise key is installed, so deploying this
before the deploy files learn the second secret cannot stop the service.
The deploy files stay as they are: #3 owns them, and the
Noise key goes in beside the service key once it lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg

frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Included in #7 (C3 and C4 joined), squash-merged to main.

@frahlg frahlg closed this Oct 13, 2026
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