Skip to content

feat(cloud): the FTW Cloud session carries the push token (C3 + C4) - #20

Merged
frahlg merged 28 commits into
mainfrom
claude/cloud-c3c4-app
Oct 13, 2026
Merged

frahlg merged 28 commits into
mainfrom
claude/cloud-c3c4-app

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Joins C3 (#19, the app reads its home from FTW Cloud) and M3/C4 (#18, notifications through FTW Cloud): the FTW Cloud session now carries the push token, and the box's word over Noise fills what the notification extension reads.

What changes

  • SiteModel.cloudHello. After adoptCloud, it writes CloudHome(home:siteId:boxKey:loadpoints:sources:offset:) to CloudKeys in the App Group, from the K11 identity. The rule U offset follows with each snapshot write; the file is written only when something changed. Turning FTW Cloud off removes the entry. The demo has no App Group files, so it writes nothing.
  • CloudHome.Offset. Rule U's anchor (boot, offsetMs, learnedAtMs) now lives in FTWShared. CloudCache.Offset is the same type, and the cache file keeps the same JSON.
  • PushAlert dates an event by rule U when the box sent no wallMs, or one more than 2 s ahead. This ports ReadAlert from feat(cloudwire): join FTW Cloud C3 and C4; alerts dated by rule U ftw#1607. The four new push vectors replay here, 18 cases in all.
  • CloudCarrier.pushRegister(apnsToken:environment:) sends push.register over the open session.
  • Push token. While FTW Cloud is on, the site model is PushTokens' sink. It sends the token when the session opens, at every reopening, and as soon as Apple gives a new one. The service keeps one row per token. Installing the sink is what asks the person to allow notifications. The sink hops to the main actor. A small PushTokenSource lets tests use their own token source instead of the process-wide one, so PushTokensTests and the cloud tests cannot step on each other when they run in parallel.
  • One ES256 check. CloudWire.verify calls PushAlert.verify(boxKey:home:t:b:sig:), so a reading and a push are checked by the same function.
  • The debug launch arguments that seeded CloudKeys (-FTWCloudHome …) are gone: the box fills it now.
  • RecordingCarrier in RemoteTests returns send's Bool, as C3's Carrier asks. Without that, the merged tests did not build.

Commits: the merge (no conflicts), 1b79dfc (the test fix), 31c63d7 (the join).

Stack

Merged on top: #18 at 42071e4 (with main 9e391d5), then #21 at 5c4c08b, which carries #19 at 77dba30 and the release Noise key. Once #18, #19 and #21 land, the diff against main is the join alone: 1b79dfc (the test fix) and 31c63d7. Merge order: #18 → #19 → #21 → this. #16 and #17 are on main already.

At 08557b3, swift test passes (265 tests in 43 suites, 89 in 9), and so does a simulator build with FTWNotify and FTWWidgets.

Checks

  • cd appleApp/FTWKit && swift test: 209 tests in 29 suites pass. New: the four rule U vectors; push.register in the service's field names, only over an open session; the token goes out at the opening, at each reopening and when Apple gives a new one, and turning FTW Cloud off releases the sink; hello shares the key and names, the next write adds the offset, and turning FTW Cloud off removes them; the demo shares nothing.
  • xcodebuild -scheme FTW -destination 'generic/platform=iOS Simulator' with its own derivedData: build succeeded.
  • push.register has no shared vector, like watch and suspend. The end-to-end run shows that the Go service reads it.

End to end (13 Oct, 10:15–10:21)

In its own iOS 26.5 simulator, against Core from srcfl/ftw#1607 and ftwcloudd from srcfl/ftw-cloud#7, built without development routes, and Apple's sandbox. I paired the simulator and turned FTW Cloud on with the consent screen. At the next hello the app read "Live via FTW Cloud", wrote cloud-<home> with the offset, and asked to allow notifications. After Allow, the service registered the token for this phone's roster entry in the same second. A charging.connected from Core reached the extension through Apple 703 ms after Core sent it. The extension verified it as an event and wrote its own words on the lock screen: "Car plugged in. Open to see charging status, check the battery level and set a ready time." The simulator is shut down.

🤖 Generated with Claude Code

frahlg and others added 20 commits October 12, 2026 20:22
…e Activity

Charge now is a Live Activity intent in FTWShared, so the Control in the
widget extension and the Live Activity's Back to the plan run in the app,
which holds the key. supportedModes is background; iOS 27 also names the
app as the only execution target. Every command needs the unlocked phone.
"Is the car ready?" answers a locked phone with the gist only, and its
card offers Charge now or Back to the plan.

LoadpointsModel starts the Live Activity on the box's cmd.result for
Charge now and ends it on Back to the plan, Pause or a read without the
hold. Static words and a timer, no power, stale after an hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
iOS 26.5's linkd treats an app whose metadata lists an included package
(extract.packagedata) as having no App Intents at all
(aggregateMetadataIsEmpty), so Siri, Shortcuts and the Control had
nothing to run. Xcode 27 already copies the FTWShared intents into the
app's and the extension's metadata without it, and iOS 27 reads both.

The Live Activity's title stays on one line, and the expanded island
puts it below the camera. -FTWDemoChargeNow presses Charge now in the
demo for screenshots (Debug only).

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

CarLevel is 20/40/60/80, as the car view's chips: each case is a tile in
Shortcuts and Spotlight, and ten were clutter. The card under "Is the car
ready?" no longer repeats the answer Siri just gave; it shows the headline
again only once a button on it has changed the state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a watch's Smart Stack the card is narrow: the timer takes the top
line next to the icon, and "Charging now since 14:02" the line below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR check builds with Xcode 26.6, whose SDK has no
IntentExecutionTargets. The iOS 27 declaration now needs Swift 6.4,
which ships with that SDK; the release build (Xcode 27.0) keeps it.

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>
FTWShared reads a push from FTW Cloud the way the Go reference
(cloudwire.ReadAlert) does: a strict CBOR reader for the envelope and
the event, ES256 over the bytes received with the box key the phone
holds, the box clock checked against the phone's, and the catalogue's
kinds only. The tests replay all 14 push vectors and the 16 messages of
cloud-vectors.json from srcfl/ftw, then sign edge cases with the vector
box key.

PushText writes every kind in Swedish and English; the English is the
push catalogue's, copied byte for byte to protocol/push-catalogue.yaml
and held to it by a test. PushActions picks buttons from a verified
kind alone. CloudKeys keeps each home's box key and id tables in the
App Group, PushSeen drops a repeated or older seq per home and boot.

The app's own strings carry the generic line under the payload's loc
keys, for when the extension does not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTWNotify (energy.ftw.app.notify) links FTWShared only and holds the
App Group alone. It checks each push with PushAlert, drops one PushSeen
has shown, and delivers new content: the words from PushText in the
person's language, the category set every time ("" when the event is
not verified), the interruption level from the kind, one thread per
home, and the checked fields beside the original payload in userInfo.
Out of time, it shows the generic line with no buttons.

The target follows the widget's: a synchronized folder, an Info.plist
that reads the group from FTW_APP_GROUP, embedded in the app, so the
shared scheme builds it.

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>
The extension first handed iOS empty content for an event it had shown.
In the simulator, `simctl push` never starts a service extension, so
whether iOS then shows nothing could not be checked there. Without
Apple's filtering entitlement, iOS is documented to show the payload's
own alert instead, and that one could carry a category the service
chose.

So a repeat gets the generic line with the category set to none, no
sound and the passive level: it lands in Notification Center and
interrupts nobody. Dropping it outright needs the filtering
entitlement, which Apple grants on request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At launch the app registers two categories in Swedish or English:
20 %, 50 % and 80 % with Open after "Car plugged in", and Charge now
with Open after "Charging stopped early" or "The car may not be ready".
The commands need the phone unlocked and run in the background; Open
brings the app up.

A pressed button checks the original payload again with PushAlert, at
the time the notification arrived, and acts only for a verified event
whose kind offers that button. CloudKeys maps the event's opaque
loadpoint to the charger (the only one, if the event names none), and
the command goes only to the home the app opens. It takes the path
Siri and the Control take: open the session, wait for the stream,
send, follow cmd.result. The box's answer is logged, nothing more.
`perform` takes the charger a notification names and never swaps it
for another; the level was already any whole percent.

The push token: a tiny app delegate (the one UIKit piece besides the
camera) hands it to PushTokens in FTWKit, which gives it to a
PushTokenSink once one is installed. FTW Cloud's channel (C3) will be
that sink, and installing it is when the person is asked; until then
a release build asks nothing. The app gets aps-environment and the
time-sensitive entitlement.

Debug builds take launch arguments for the end-to-end test:
-FTWPushRegister YES asks and registers at launch and logs the token,
and -FTWCloudHome, -FTWCloudBoxKey, -FTWCloudLoadpoint,
-FTWCloudSource and -FTWCloudSite seed one home's CloudKeys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`-FTWPairLink <url>` offers the link as a scanned QR would. The simulator
has no camera, and the FTW Cloud C4 end-to-end test pairs a simulator with
a local Core this way.

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>
C3 made Carrier.send return whether the frame went out; M2's test
carrier still returned nothing, so the merged tests did not build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Joins C3 (the app reads its home from FTW Cloud) and C4 (notifications
through APNs).

- When the box's hello says FTW Cloud is on, the site model writes the
  box key, the names and rule U's anchor to CloudKeys for the
  notification extension; the anchor follows at each snapshot write.
  Turning FTW Cloud off removes the entry. The demo writes none.
- While FTW Cloud is on, the cloud session is PushTokens' sink. It
  sends push.register when the session opens, at every reopening and
  when Apple gives a new token. The service keeps one row per token.
- PushAlert dates an event by rule U when the box sent no wallMs, or a
  wallMs too far ahead, as ReadAlert now does in srcfl/ftw. The four new
  push vectors replay here.
- CloudWire.verify calls PushAlert's ES256 check, so a reading and a
  push are checked by one function.
- The debug launch arguments that seeded CloudKeys are gone; the box
  fills them now.

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>
frahlg and others added 6 commits October 13, 2026 10:51
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>
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>
main has M2 (#17, squashed), Now detail (#16) and #22. Every conflict
took main's side, then this branch's own changes since the M2 tip it was
cut from (2c946bf) went back on top:
- IntentRunner and LoadpointsModel keep main's guards for another home's
  card and the shared-record flush, and gain the notification's
  loadpointID.
- The Swedish strings keep main's lines and add the push fallback.
- project.pbxproj is main's (with FTWUITests and FTWCharts) plus the
  FTWNotify target's entries, placed at the end of each section.

swift test: FTWShared 89 tests, FTWKit 179 tests, all pass. The simulator
build embeds FTWNotify.appex and FTWWidgets.appex.

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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… join

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 13, 2026 14:19
@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:24:53.934524Z 08557b3 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.

@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: 08557b3b22

ℹ️ 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 +121 to +122
if wait {
write(name, data.byteArray)

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 Queue the final cache write behind pending writes

When the app is suspended while one or more writeLater tasks are pending, the wait path writes the newest snapshot synchronously without waiting for that queue. A previously queued older write can then finish afterward and atomically replace the final snapshot, so the next launch restores stale readings or cloud metadata despite persistNow(wait: true) intending to land the latest state before suspension.

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 the cloud capability appears

If a consenting owner initially has an older box without cloud.link, ensure correctly defers the grant, but a later hello after the box updates changes only session.caps. Because this task identity omits the capability/audience and heardFromBox, cloudOn, and answered remain unchanged, SwiftUI does not rerun consent.ensure; FTW Cloud therefore stays off until another launch or manual action despite the UI promising activation after the update.

Useful? React with 👍 / 👎.

Comment on lines +122 to +125
let granted = try await site.callBox(.post, "/api/cloud/grant", body: .object([("scopes", scopes), ("policy", .string(Self.policy))]))
box = granted.map(BoxState.init)
if contributeStats {
box = try await site.callBox(.post, "/api/cloud/stats", body: .object([("policy", .string(Self.statsPolicy))])).map(BoxState.init)

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 Handle grant and statistics updates as separate operations

When statistics are selected, the cloud grant can succeed and the subsequent /api/cloud/stats request can fail. The shared act then reports failure, leaves the per-home asked marker unset, and displays that nothing changed even though the box has already enabled FTW Cloud; this can also trigger redundant grant attempts. Preserve the successful grant state and report or retry the statistics failure separately.

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