Skip to content

feat(cloud): the iPhone app reads its home from FTW Cloud first (C3) - #19

Closed
frahlg wants to merge 9 commits into
mainfrom
claude/cloud-c3-app
Closed

frahlg wants to merge 9 commits into
mainfrom
claude/cloud-c3-app

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

FTW Cloud C3, the app's part: the iPhone app reads its home from FTW Cloud first. Draft.

Pairs:

Based on #15, which has the registry names (cloud, cloud.link), so the diff here is C3 alone.

A cold start in three layers

  1. The first frame comes from the phone's cache, as before.
  2. The cloud's copy arrives from memory one round trip after the socket opens. CloudCarrier opens one WebSocket to wss://cloud.ftw.energy/v1/app?home=<id> and runs FTWKit's Noise IK as initiator to the service's key. Message 1 says the phone is looking; message 2 is the snapshot.
  3. Every change follows on the same socket, as the box's own signed envelopes.

The phone checks every envelope.

  • CloudWire and CloudView port go/protocol/cloudwire (frames, envelope, ES256 with CryptoKit P-256, home id) and its freshness rules.
  • The box's key comes from the box, in hello_ok.cloud over Noise, never from the service.
  • There is never live without a valid anchor:
    • rule N: the earlier of wallMs and arrival, and off for a boot whose clock leads by more than 2 s;
    • rule U: learned from the box session, kept with the identity for the next cold start, with 0.01 % drift and a 24 h life.
  • A replayed copy ages, a forged one is dropped, and a copy without an anchor shows as "FTW Cloud · age unverified".
  • The tests replay both golden vector files byte for byte, the whole app session from fixed keys included, and all 16 freshness cases.

SiteModel: the cloud first, the relay as backup

  • With FTW Cloud on, SiteModel takes readings from the cloud. The box session over the relay stays open beside it for commands, prices and chargers, as today.
  • The relay's readings take over when the cloud fails, is unreachable, or has nothing better. A box session that streams live wins over a cloud copy that is not live.
  • The band names the carrier the readings came by: "Live via FTW Cloud", or "Live via encrypted relay".
  • session keeps its shape, so no screen changed. The cloud's readings arrive under the box session's names, from the box's table, with their ages.
  • The App Group cache is still written: the snapshot, and the cloud's prices and cars for the widgets, labelled cloud.
  • The background closes the cloud socket too.
  • Fixed address. It is cloud.ftw.energy, and only a debug build may point elsewhere: -FTWCloudURL wss://127.0.0.1:18443/v1/app -FTWCloudKey <base64url>. Release builds carry no service key yet (CloudEndpoint.releaseKeys is empty), so TestFlight reads through the relay alone until the key exists.

Consent at first start, on one screen (FTW/Cloud/**)

  • The screen says "The FTW app uses FTW Cloud" and that it is part of the service. Statistics have their own box, off until ticked. The text is in English and Swedish.
  • An owner's phone turns the grant on at the box over Noise (POST /api/cloud/grant, all five cloud scopes, policy ftw-cloud-2026-10), once per home. Statistics go on only when ticked.
  • A viewer reads "Ask the owner of this home to turn on FTW Cloud."
  • A box that cannot run the uplink yet gets the grant once it can.
  • After the first ask, the box's word stands. A grant turned off in About or on the box is never turned on again by the app.
  • About gains an FTW Cloud row. An owner turns FTW Cloud and statistics on and off there. The receipt shows as "Copy erased today 09:07".
  • The privacy footer in About changes. "Sourceful cannot read them" was no longer true for readings with FTW Cloud on, so the footer now says what FTW Cloud holds.

Files outside this session's list

The line in RootView, as asked:

        .cloudConsent(app)

These were also needed. Each change is small:

  • AboutView.swift: the FTW Cloud row and the privacy footer.
  • AppModel.swift: one line in connect, site.connectCloud(deviceKey: device, makeSocket: makeSocket).
  • Session.swift: SessionState.cloud, set in onHelloOk.
  • Messages.swift: HelloOk.cloud.
  • Contract.swift: capCloudLink, cloudScopes, cloudPurposes; a test checks them against the registry.
  • Freshness.swift, sv.lproj/Localizable.strings: the band's cloud words.
  • Readings.swift: one comment.

#16 touches RootView, Session.swift, AppModel.swift and Contract.swift, and #17 touches the Swedish strings. Neither shares a hunk with this PR.

Measured, locally

The setup was a Core from srcfl/ftw#1603, ftwcloudd from srcfl/ftw-cloud#5 on loopback, a simulated house and the production relay, with the laptop on a phone's hotspot.

The app's code in-process on the Mac (LiveCloudTests), five cold starts each, new sockets each time:

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 app in the iOS 26.5 simulator (debug build), five cold launches. The first frame is counted from process start; the rest from SiteModel.start.

Launch First frame Cloud snapshot Relay, first reading First cloud update
1 760 refused 825 –
2 676 253 685 2,062
3 495 245 492 1,856
4 724 258 717 1,996
5 493 248 482 2,114
  • Launch 1 was refused. It came right after the service restarted, before the box had reconnected.
  • An earlier set of five launches had no relay at all. It fell in the hour's rendezvous rotation, when the app had moved to the new handle and the box moved three minutes later. The cloud carried the screen live through it: first frame 527–657 ms, snapshot 214–291 ms, first update 785–2,707 ms. That gap is in the relay path and existed before C3.
  • The snapshot is not network-bound. It lands about 50 ms after the first frame. Most of its 250 ms is the launch itself, the vault and the Keychain, which the relay path waits on too.
  • Layer 3 follows the house. The cloud sends on change, at most once a second while watched. The simulated house changed at gaps of 1.0, 3.5 and 4.5 s.
  • Production was not tested. cloud.ftw.energy runs C2 without /v1/app, and deploying C3 is not this PR's to do. From the same laptop it answered TLS in 70–132 ms, so layer 2 over mobile data would be about four round trips, near 150 ms.

Proofs:

  • A replayed snapshot, the real bytes an hour late, grades all sources down and never live.
  • A revoked phone's cloud stream closed 3 and 12 ms after DELETE /api/app-link/devices/{id}.
  • With ftwcloudd stopped, the carrier moved to relay within 11 ms, and the band said "Live via encrypted relay" in the simulator.
  • Turning FTW Cloud off in About sent DELETE /api/cloud/grant over Noise, and the box held the purge receipt 7 ms later.
  • The simulator cannot toggle airplane mode. The closed-cloud case and the unit tests cover the code path, but it needs a check on a device.

The simulator also showed one bug, fixed and tested here: consent given while FTW Cloud was already on did not record that the home had been asked, so turning it off in About let the next start turn it on again.

I reviewed the screens in the simulator in English and Swedish, light and dark, and at the largest text size.

Review fixes (f3c2ff6, 329dfce)

A review of the app found faults in the parts this PR owns. Each fix has a test in ReviewFixTests.

What the band says

  • A cold start says "Connecting to your box". Every open used to say "Reconnecting", because the app woke a carrier it had just made. It now says "Reconnecting" only after a real drop.
  • After 20 s the band stops counting. It says "Can't reach your box · last reading 14:02" ("Når inte din box · senaste mätvärde 14:02").
  • The band and the cards read one snapshot. The simulator showed "Live via encrypted relay" beside a card that said "Not live right now", while a burst waited for its frame. carrier, srcState, ageMs and isLive now come from a snapshot taken in the same change as session.

Commands

  • Carrier.send returns whether the frame left. A command on a carrier that is not open throws E_NO_ACK at once, not after the 5 s ack wait. The same holds when a send fails. This covers every carrier: relay, Noise, cloud and the simulated box.

Readings that move smoothly

  • Each value keeps the time it was read (fieldAtMs, readingAtMs).
  • A burst reaches the screens as one change. Changes publish at most once per 16 ms, with a leading and a trailing edge.
  • Numbers never step back. A copy that is not live replaces what is shown only if it is at least as new. A cloud copy older than the cache waits until the cloud has something newer.

The clock and the disk

  • The 1 s ticker is gone. A clock at each minute boundary takes its place, with one-shot deadlines: the stream going quiet, the cloud's next grade change, and each second only while the band counts.
  • Snapshots are sealed off the main actor, and only when they changed. The background writes once and waits for it.

A locked Keychain writes nothing

  • Before the first unlock the Keychain answers errSecInteractionNotAllowed. SecureStore.read now throws SecureStoreUnavailable for that case.
  • Then Vault, SealedFiles and SiteList write nothing: no new wrap key over the old one, no cache key, no home list without the other homes, and no new pairing.

Time to live

  • LaunchClock.mark("live") marks the first live reading after process start.
  • Signpost intervals cover "cold start to live" and "foreground to live". A cold start passes through inactive, and keeps its own interval.

Measured after the fixes, in the iOS 26.5 simulator, five launches each:

Build First frame Cold start to live Live, from process start Foreground to live
Debug, cloud and relay 485–656 ms 221–320 ms, via cloud 560–725 ms 35–43 ms, via cloud
Release, relay alone 545–933 ms 500–764 ms, via relay 868–1,449 ms –
  • The first frame misses the 400 ms target in the simulator. It needs a device and Instruments to say where the time goes.
  • I did not measure body updates per second or the snapshot write time on a device.

Left for others

Merging with #16 and #17. This branch merges with each of them without a text conflict.

Files outside this session's list, all small:

  • Carrier.swift, RelayCarrier.swift, NoiseCarrier.swift, FTWDemo/SimulatedBox.swift: send returns Bool.
  • Session.swift: command fails at once on a closed carrier.
  • SecureStore.swift, FTW/Platform/KeychainStore.swift, Sites.swift, SealedFiles.swift: the locked-Keychain case.
  • Freshness.swift, sv.lproj/Localizable.strings: the long-wait text.
  • FTW/UI/LaunchClock.swift hands its marks to the new Support/ProcessClock.swift. This file sits in iPhone Now: tap a node for live readings, history, plan and mode; the plan ahead on Now and as a widget #16's area.

Tests

  • swift test: FTWKitTests 193 tests, FTWSharedTests 35, all passing. The 33 cloud wire and view tests are included.
  • New suites: CloudCarrierTests, CloudEndpointTests, SiteModelCloudTests, CloudConsentTests, ReviewFixTests.
  • LiveCloudTests runs only when its environment names a box and a service.
  • The app and its widgets build for the simulator, in Debug and in Release.

For Fredrik before a release turns this on

  • The production service needs a Noise key. It goes into CloudEndpoint.releaseKeys and onto the host.
  • The App Store privacy answer and the privacy policy must change. "Data Not Collected" is not true once FTW Cloud reads a home.
  • C4 plans to build on this. Its session (claude/ios-m3-notify) expects FTWShared CloudKeys to be written from hello_ok.cloud, and this socket installed as its PushTokenSink. SiteModel.cloudIdentity and CloudCarrier are the places.

🤖 Generated with Claude Code

frahlg and others added 5 commits October 12, 2026 21:00
protocol/registry.yaml is the byte-for-byte copy of
srcfl/ftw-webapp contract/registry.yaml, which now holds scope
ftw.events.read, capability cloud.link, carrier state cloud and the
cloud_scopes and cloud_purposes lists. Contract and CarrierKind follow
so ContractTests still reads both directions. Nothing produces the
cloud carrier yet; that is C3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudWire is the phone's half of the FTW Cloud wire: the unpadded lane 1
frame, the {t, b, sig} envelope, the signed input and its ES256 check
with CryptoKit, the home id, the app session's prologue and snapshot,
and the bodies of the classes a phone reads. Bodies decode as Go's
decoder does: unknown keys ignored, a known key of the wrong type
refused.

CloudView ports Go's cloudwire.View: the highest seq per class and boot,
boot replacement only by a later anchored message, rules N and U with
the drift margin and 24-hour lifetime, and the state rules, which can
only make the box's own grades worse. It is a plain Sendable value with
no clock, so an extension can run the same rules. It also keeps the
newest prices and hb for the screens, outside the grades.

The tests replay srcfl/ftw's cloud-vectors.json and
cloud-app-vectors.json, copied unchanged from cloud-c3-core at cc6b36cb,
and all 16 freshness cases in docs/protocol-cloud.md, with the box
signing by the vectors' key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudCarrier opens one WebSocket to wss://cloud.ftw.energy/v1/app and
runs Noise IK as initiator to the service's key with the home id in the
prologue. Message 2 brings the copy the service holds in memory; every
change the box sends follows on the same socket. Each envelope is
checked with the box's P-256 key, which the box gives every role in
hello_ok over its own Noise session, and graded by CloudView: never
live without an anchor, a replayed copy ages, a forged one is dropped.

SiteModel reads the cloud first while the home has a grant and the
box session over the relay stays open beside it for commands. The
relay's readings take over when the cloud fails, is out of reach or has
nothing better, under the carrier the readings came by. Rule U is
learned from the box session and kept with the box's FTW Cloud identity
for the next cold start. Prices and cars from the cloud go to the
widgets as carrier cloud.

The first start shows one screen: the FTW app uses FTW Cloud, with a
separate statistics box that is off. An owner's phone turns the grant on
at the box over Noise, once per home; a viewer is told to ask the owner.
About gains an FTW Cloud row to turn it off and show the receipt, and
its privacy line says what FTW Cloud holds when it is on. The address is
fixed and only a debug build may point elsewhere. Release builds carry
no service key yet, so they read through the relay alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refusal from FTW Cloud is asked again slowly, from 7.5 s up to five
minutes, and at once on wake: a service that just restarted refuses a
phone that reaches it before the box does. A session another session of
the same phone replaced stays closed.

SiteModel logs, once per home, how long after start the relay's first
reading, the cloud's snapshot and its first live arrived (category
reading), beside LaunchClock's first frame.

LiveCloudTests drives a real box and a real ftwcloudd through the
production relay, off unless the environment names them: it times the
three layers against the relay five times each, replays a snapshot an
hour late, revokes a phone with its stream open and closes the cloud
under a running home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consent given while FTW Cloud was already on never noted that the home
had been asked, so turning it off in About let the next start turn it
on again. Found in the simulator. ensure now notes an existing grant,
and turning off in About notes it too. The consent sheet takes the
accent tint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review points in SiteModel and the carriers:

- A cold start no longer says "Reconnecting to your box". connect woke
  the carrier it had just made, which reported a reconnect that never
  happened; it now only arms the foreground deadline.
- Carrier.send returns whether the frame went out, and a command on a
  carrier that is not open fails at once with E_NO_ACK instead of after
  the 5 s ack deadline. CloudCarrier's sends say so too.
- The 1 Hz ticker is gone. The clock moves with each reading and, between
  readings, only when something on screen would change: the minute, a
  stream going quiet, a cloud grade lapsing (CloudView.nextGradeChange),
  or a counter the band shows in seconds. minuteMs is there for text that
  needs no finer clock.
- After 20 s without a connection the band says "Can't reach your box ·
  last reading HH:MM" and stops counting.
- Snapshots are sealed and written off the main actor with the cache key
  kept in memory, are skipped when nothing changed, and the background
  writes once.
- A keychain that cannot answer, as before the first unlock, never leads
  to a write: SecureStore.read tells absent from unavailable, and keys
  are made only when absent.
- ProcessClock marks the first live reading beside LaunchClock's first
  frame, and signpost intervals time cold start to live and foreground
  to live.

For flowing numbers: every value carries the time it was read
(fieldAtMs, readingAtMs), a burst within one display frame reaches the
screens as one change, and a source only takes over with a copy at least
as new as the one on screen, so numbers never step back.

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

Found in the simulator: while a burst waited for its display frame, the
band read the newest relay state and said "Live via encrypted relay"
while the card still said "Not live right now". carrier, srcState,
ageMs and isLive now derive from a snapshot taken in the same change as
session, and the cache's claim is retired in that change too. Closing
the cloud publishes at once, and the background settles what the
screens say before it writes.

A cold start passes through inactive on its way to the front; it keeps
its own cold-start-to-live interval instead of starting a foreground
one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 2 commits October 13, 2026 16:10
Two fixes the merge needs: the comment on CarrierKind.cloud keeps C3's
"signed by the box", which is now true, and M2's RecordingCarrier
returns what send returns, as C3's Carrier asks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AppModel.connect catches PasskeyCancelled. A closed sheet now says the
  unlock was cancelled and offers Try again, instead of saying the key is
  lost. Every help text of ConnectError and VaultError, and the one for a
  failure connect cannot name, goes through AppWords with Swedish. A test
  reads them from the source, so a new one needs Swedish too.
- SiteModel.sharePrices passes the box's time zone to PriceEntry, so the
  price widget reads the clock the way the home does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 13, 2026 14:15
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T14:20:17.270426Z 77dba30 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

frahlg added a commit that referenced this pull request Oct 13, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 13, 2026
… join

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

@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: 77dba308ff

ℹ️ 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 on lines +533 to +535
if shown.source == .cloud {
if shown.cloudOpen { return .cloud }
return cachedAtMs != nil ? .cache : .none

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat the cloud carrier as live in node freshness

Whenever a valid cloud snapshot is selected, this exposes .cloud to every SiteModel.freshness(...) call, but NodeFreshness.live in State/EnergyNode.swift:93-95 recognizes only .relay and .webrtc. Consequently every cloud-fed flow and node is rendered muted/non-live even while SiteModel.isLive and the freshness band report “Live via FTW Cloud”; include .cloud in the node-level live-carrier predicate.

Useful? React with 👍 / 👎.

Comment on lines 606 to 609
public var sinceLastFrameMs: Double {
guard let at = lastFrameAtMs else { return 0 }
guard let at = shown.relayFrameAtMs else { return 0 }
let since = nowMs - at
return since < Self.streamQuietAfterMs ? 0 : since

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop aging cloud readings from the relay timestamp

When the relay has delivered at least one frame and then drops while cloud updates continue, shown.relayFrameAtMs remains fixed, so this value crosses the quiet threshold and is passed to NodeFreshness.of, which downgrades otherwise-live cloud sources to lagging and keeps increasing their displayed age. Cloud delivery never refreshes this relay timestamp, so the calculation must use the selected source’s timestamp or omit relay silence while shown.source == .cloud.

Useful? React with 👍 / 👎.

Comment on lines +373 to +376
private func composed() -> SessionState {
var s = relay
if source == .cloud, let view = cloudView, let identity = cloudIdentity {
compose(&s, view: view, identity: identity)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Record cloud updates while the relay is unavailable

When FTW Cloud is carrying the home during a relay outage, composing from relay preserves its .failed phase even though publishNow advances lastFrameAtMs for each cloud reading. LiveTrace.take in State/LiveTrace.swift:98-101 rejects every such update unless session.phase == .streaming, so the live charts stop or remain empty precisely when cloud is the only working path; make trace recording depend on the selected reading carrier rather than the relay phase.

Useful? React with 👍 / 👎.

Comment on lines +31 to +33
private var ensureKey: String {
guard let site = app.site else { return "" }
return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)"

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 Retrigger consent when cloud capability becomes available

If consent is accepted while the box is .ownerLater, a later hello after the box updates can add capCloudLink without changing any component of this task ID: siteId, heardFromBox (already true), cloudOn (still false), and answered (already true). SwiftUI therefore does not rerun consent.ensure, and the promised automatic grant is never sent until the view or app is recreated; include the relevant capability or derived audience in the task identity.

Useful? React with 👍 / 👎.

frahlg added a commit that referenced this pull request Oct 13, 2026
…d first (#18, #19, #21, #20)

One squash for four stacked PRs:
- #18: M3/M4, notifications you can act on, delivered through FTW Cloud, with the FTWNotify extension that checks the box's signature.
- #19: C3, the app reads its home from FTW Cloud first and falls back to the relay. It also passes the box's time zone to the shared prices and gives a cancelled unlock its own text, in English and Swedish.
- #21: release builds dial FTW Cloud with its production Noise key.
- #20: C3 + C4, the FTW Cloud session carries the push token.

A live run of a release build against cloud.ftw.energy showed "Live via FTW Cloud" at a median of 262 ms from a cold start, against 611 ms through the relay.

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

frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Included in #20, merged as 6a4e2ba (#20 merged this branch in whole).

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