Repository navigation
Conversation
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>
CloudEndpoint.releaseKeys carries the public half of cloud.ftw.energy's Noise key, /ftw-cloud/noise-key in Parameter Store. A release build now reads from FTW Cloud once the box says FTW Cloud is on; a debug build does the same unless launch arguments point it at a local service. Two tests: the release endpoint is cloud.ftw.energy with exactly that key, and a service that answers with any other key refuses message 1, so the phone opens no session and holds no snapshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
The one conflict is a comment in Readings.swift: C3's "signed by the box" is now true, so it stays. Co-Authored-By: Claude Opus 5.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: 57afd80056
ℹ️ 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".
| public init(defaults: UserDefaults = .standard) { | ||
| self.defaults = defaults | ||
| answered = defaults.string(forKey: Keys.answered) == Self.policy | ||
| contributeStats = defaults.bool(forKey: Keys.stats) |
There was a problem hiding this comment.
Clear cloud consent when signing out
Persisting both consent and the statistics choice in global UserDefaults lets them survive AppModel.leave(), which clears the keychain, sites, and caches but not these keys. If the phone is handed to another person and paired with a different home, CloudGate skips the consent sheet and ensure can enable FTW Cloud—and statistics when the previous person selected it—without the new person making either choice. Reset these user-scoped defaults during sign-out or bind them to the enrolled identity.
Useful? React with 👍 / 👎.
| private var ensureKey: String { | ||
| guard let site = app.site else { return "" } | ||
| return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)" |
There was a problem hiding this comment.
Include cloud capability in the consent task identity
When consent is accepted while the box is .ownerLater, this task runs and ensure returns because cloud.link is absent. If that same SiteModel later reconnects after a box update and the new hello adds cloud.link, every component of ensureKey remains unchanged (heardFromBox was already true and cloudOn remains false), so SwiftUI does not rerun the task and the promised automatic grant never happens until the view is recreated. Include the capability/audience in the task ID.
Useful? React with 👍 / 👎.
| if wait { | ||
| write(name, data.byteArray) | ||
| } else { | ||
| writeLater(name, data.byteArray) |
There was a problem hiding this comment.
Serialize final cache writes with queued writes
A wait: true write bypasses the writing task chain. If a periodic asynchronous snapshot is still sealing when the app backgrounds, suspend() writes the newer snapshot synchronously, but the older queued write can subsequently finish and atomically overwrite it; the next launch then restores stale readings despite the background write being intended as the final state. The synchronous/final path must wait for or invalidate pending writes to the same file.
Useful? React with 👍 / 👎.
| socPct: l.socPermille.map { (Double($0) / 10).rounded() }, | ||
| socSource: l.socSource, | ||
| socReadAtMs: l.socAgeS.map { now - Double($0) * 1000 }, | ||
| powerW: Double(l.powerW), |
There was a problem hiding this comment.
Account for cloud message age in car timestamps
socAgeS is the vehicle reading's age when the box sent the LP message, but this computes its wall time relative to phone receipt. When FTW Cloud delays or replays an otherwise valid LP envelope, the cached widget record therefore makes the vehicle SoC look newer by the entire message delay (for example, a ten-minute-old envelope with socAgeS == 60 is recorded as only one minute old). Derive the timestamp from the authenticated LP header's send time, then subtract socAgeS.
Useful? React with 👍 / 👎.
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
Release builds of the app now dial FTW Cloud.
CloudEndpoint.releaseKeyscarries the public half of the Noise key ofcloud.ftw.energy:6-1WbbDzKvbwODSwhUN24MPemfLNRhcbMP2tpU_QAEkThe private half is the SecureString
/ftw-cloud/noise-keyin Parameter Store (srcfl/ftw-cloud#9,deploy/noise.md). Nobody printed or read it. The app still reads from FTW Cloud only once the box's hello says FTW Cloud is on. A debug build does the same unless-FTWCloudURLand-FTWCloudKeypoint it at a local service, as before.Stack
This PR is stacked on #19, the only place
CloudEndpointexists so far. Until #19 lands, the diff againstmainincludes #19; after that, only this commit (appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swiftandTests/FTWKitTests/CloudCarrierTests.swift). #20 needs it too; I will mergemaininto #20 when this lands.mainis merged in (7907cc9). Its one conflict is a comment inFTWShared/Readings.swift: #15 landed as a squash with "Nothing produces it yet", and #19 says "signed by the box". I kept #19's line. #19 itself has the same conflict againstmain.Rotation
The app carries up to two keys and tries the next when the service refuses message 1. Ship the new public half beside the old one, wait for phones to update, switch the service (
put-parameter --overwrite, then restart the units), and drop the old half in a later release. The comment onreleaseKeyssays the same.Tests
cd appleApp/FTWKit && swift testpasses: 195 tests in 26 suites, and 35 in 4. New:aReleaseBuildCarriesTheServicesKey: exactly one 32-byte key, the text above. A release build and a debug build without an override both resolve towss://cloud.ftw.energy/v1/appwith that key.aServiceWithAnotherKeyIsNotTheCloud: a carrier with the release key against a service with another key gets 4403 after message 1, never opens, and holds no snapshot./v1/appatcloud.ftw.energyanswers 501 until srcfl/ftw-cloud#9 is deployed andnoise.shhas run, so a live run against the service waits for that.🤖 Generated with Claude Code