Skip to content

feat(cloudwire): join FTW Cloud C3 and C4; alerts dated by rule U - #1607

Merged
frahlg merged 18 commits into
masterfrom
claude/cloud-c3c4-core
Oct 13, 2026
Merged

frahlg merged 18 commits into
masterfrom
claude/cloud-c3c4-core

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Joins FTW Cloud C3 (#1603) and C4 (#1602) in one branch, so srcfl/ftw-cloud and the app can pin a go/protocol that has both cloudwire/app.go and cloudwire/push.go. Adds rule U to the alert reference.

What changes

  • The merge. One conflict, in go/cmd/ftw/cloud_link.go: C3's cloudSession and C4's cloudEventQueue were added in the same place. Both stay.
  • Rule U for alerts. ReadAlert takes the rule U offset the phone holds for a home. It dates an event the way View dates a message: the earlier of rule N and rule U, and when rule U gives the time, it subtracts the drift margin so the margin counts against the age. Before this change, an event from a box without NTP, or with a clock more than 2 s ahead, always showed the generic line. Now it gets its words and buttons when the phone has learned an offset over Noise. The protocol's Push table already says "anchored", so the doc stays as it is.
  • Four push vectors: no box clock, a box clock ahead, an offset from another boot, and an offset learned more than a day ago. -update now keeps stored signatures that still verify, so the 14 old cases keep their bytes and the file only grows.

Stack

Draft, stacked on #1595 → #1603 + #1602. When they land, rebase this branch; the diff then shrinks to a9e6f66. Pairs: srcfl/ftw-cloud#7 pins this commit, and srcfl/ftw-app#20 ports rule U to PushAlert and replays the four vectors.

Checks

  • go/protocol: go vet ./... && go test ./... pass.
  • go: go test -race ./cmd/ftw ./internal/cloudlink ./internal/notifications ./internal/loadpoint pass.
  • gofmt -l flags go/protocol/cloudwire/vectors_test.go. The C3 and C4 heads both have the same problem, so I left it alone.

End to end, with Core from this branch, see srcfl/ftw-cloud#7: a real app session registered its token, and a charging.connected from Core reached the simulator through Apple's sandbox, 703 ms after Core sent it.

🤖 Generated with Claude Code

frahlg and others added 17 commits October 12, 2026 20:19
Copied byte for byte from srcfl/ftw-webapp, which holds the source:
scope ftw.events.read, the cloud_scopes and cloud_purposes lists,
capability cloud.link, carrier state cloud, and push kinds
cloud.granted and cloud.revoked. The generator now writes CloudScopes
and CloudPurposes into go/protocol, and the registry header names the
file it actually generates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
go/protocol/cloudwire holds what docs/protocol-cloud.md specifies:
the unpadded lane 1 frame, the {t, b, sig} envelope and its ES256
signing rule, home and opaque ids, the message bodies, the scope of
each class and the field allowlist. A test fails when a body grows a
field the allowlist lacks, and compares the allowlist with the
protocol document's tables when the document is present.

Golden vectors in testdata/cloud-vectors.json freeze the bytes for
srcfl/ftw-cloud and the phone. View is the phone's freshness rules as
a reference, with the document's cases as tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
go/internal/cloudlink is the box's uplink to FTW Cloud. It is off until
an owner grants it, and a grant whose key fingerprint does not match
cloud.key reads as off.

- One WebSocket over TLS 1.3 with no proxy. The box sends nothing but
  hello until the service has signed a hello_ok bound to this TLS
  session's exporter value and the box's nonce.
- Every message is signed with cloud.key and carries seq, boot,
  uptimeMs and wallMs while NTP has set the clock.
- live, lp and prices go on change, at most every 5 s (1 s while
  watched, lp at least 2 s); a table change goes at once with a new
  srcRev; hb goes after H and never while a changed live or lp waits.
- One writer per socket: a slow socket gets the newest values, nothing
  queues, and a write blocked for 10 s closes it. Past 20 MB in 24 h
  of uptime only roster and bye go.
- Driver and loadpoint names become opaque ids; capability fields are
  not in the shapes. The downlink is a closed set of five types, under
  a fuzz test.
- A revoke sends bye with purge and keeps the key until the service's
  signed receipt arrives.

CI runs go list -deps to keep control, API and loadpoint code out of
the package's build graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- GET /api/cloud and /api/cloud/preview for an owner: the grant, the
  bytes sent in 24 hours, and the exact frames that left the house.
- POST /api/cloud/grant from an owner's session, or on the LAN only
  from the box itself or with the house password. DELETE from any door.
- Enrolment lists whole keys for the signed roster and says when who is
  paired, or as what, has changed.
- The adapters read the live snapshot the app session sends, the
  loadpoint view GET /api/loadpoints composes and the price store.
- Owners hear cloud.granted and cloud.revoked; these two rules are on
  by default, unlike the rest.
- Backups skip cloud.key, so a restored box has FTW Cloud off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A charging car moves its measured power every reading, so lp went at
every period: about 12 MB a day on a simulated site, against an
estimate of 5 MB. lp now counts power and actual watts as changed only
past max(100 W, 5 %), session energy past 100 Wh and charge level past
1 %. A change of state still goes at once, the full lp every 15 minutes
carries exact values, and live field 10 carries the charging power at
every period.

Also say which way a dev root CA file failed to load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sender looked at live, loadpoints and the roster number on every
250 ms tick. On a Pi the loadpoint view composes control feedback, and
the roster number is a SQLite read. Now live is read once a second (so
a table change still goes within a second), loadpoints once a period,
and the roster only when it changed, at connect and every six hours.
An hb still reads fresh ages on the tick it goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service closes the uplink right after it sends purged. The receipt
went to the sender through a channel, and the reader could see the
close first and end the connection with the receipt unread; the box
then had to ask again. The reader now checks the receipt itself and
ends the connection only after the key and the grant are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone reads its home from FTW Cloud over Noise IK with the service as
responder. cloudwire gains the prologue, the now, watch, suspend and
snapshot bodies, the close codes, and golden vectors for one whole
session. appwire gains the initiator half of IK, checked against the
same Cacophony vector as the responder, for the service's tests and any
Go client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A responder that admits initiators by key decides before message 2
carries anything. FTW Cloud checks the phone's key against the box's
roster there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTW Cloud C4 carries notifications. cloudwire gains the event class body
(eventId, ts, kind, severity, args, src, lp), the phone's push.register
and push.unregister, and the APNs payload the service builds: the same
aps for every alert, and the box's envelope unchanged under "ftw".
ReadAlert is the reference for what a phone's notification extension
makes of a payload: a fresh, anchored, box-signed event of a known kind,
the service's own box.unreachable, or the generic line.

The catalogue gains charging.goal_at_risk. cloud-vectors.json gains three
signed events; push-vectors.json holds fourteen payloads, the phone's
clock and the verdict each must get, for the service and the app to
replay.

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

hello_ok gains an optional `cloud` field for every role while an owner
has FTW Cloud on: the box's FTW Cloud key, the home id, the uplink's boot
id and the tables from opaque source and loadpoint ids to names. A phone
checks what the service forwards with that key, never one the service
gives it. Floor mode leaves it out.

hello_ok says cloud.link when this build names a service. Release builds
name none and stay silent.

POST /api/cloud/stats {policy} adds the stats purpose under a grant
already on (409 when off or revoking), draws a 16-byte statsId once and
keeps it, saves the grant, marks the roster due and writes the audit
line. DELETE /api/cloud/stats removes the purpose and forgets the id. No
statistics are sent yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
View.Accept reset the view for a new boot, and turned rule N off,
before it had decoded the body and checked seq, so a signed message it
then dropped could still wipe what the phone held. It now checks
everything first. An unknown source state ranked as live when it was
the second argument of worse; it ranks as never on either side. Found
by the Swift port. Adds the doc's case 11 and a changeset for C3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
K5 and the Core half of FTW Cloud C4.

- A catalogue message carries its kind, its arguments, the device it
  concerns and when it happened (dispatchCatalogue set no kind before).
- Every transport publishes side by side under its own 10 s limit, so a
  gateway that hangs costs only itself.
- notifications.CloudPublisher only queues. cloudlink.QueueEvent keeps up
  to 32 events; the writer signs each as an event with eventId, ts and
  seq, cuts opaque ids for the driver and loadpoint, and drops name
  arguments. An event older than ten minutes is dropped and counted in
  GET /api/cloud (eventsDropped). The grant's events scope gates it all.
- Link.Bye sends bye{why:"update"} for a restart the box chose, and
  bye{why:"shutdown"} on SIGTERM, then stops dialling, so FTW Cloud holds
  back box.unreachable through an update.
- charging.goal_at_risk: once per plug-in, from a fresh plan's shortfall
  (at least 1 kWh), only on a level that is not assumed and a ready time
  still ahead. Opt-in like the other charging rules.
- charging.session_complete for a car that reports nothing: when the
  box's estimate from a level someone set reaches the target, in kWh,
  once, and not after a restart beside a car that is already done.
- charging.connected waits 90 s and goes only when the level is assumed
  or no goal is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /charger/{id}/stall makes the simulated charger deliver nothing
while it still reports Charging, as a tripped relay does; /resume lets it
go on. It is what charging.interrupted is for, and the FTW Cloud C4
end-to-end test raises one with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e/cloud-c3c4-core

# Conflicts:
#	go/cmd/ftw/cloud_link.go
A box without NTP sends events with no wallMs, and the phone showed them
as the generic line even when it held an uptime offset for that boot.
ReadAlert now takes the offset the phone keeps for a home and anchors an
event as View anchors a message: the earlier of rule N and rule U, with
rule U's drift margin counted against the age. The protocol's Push table
already says "anchored", so the text does not change.

Four push vectors cover rule U: no box clock, a box clock ahead, an
offset from another boot, and one learned over a day ago. -update now
keeps stored signatures that still verify, so adding a case leaves the
other cases' bytes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 13, 2026 08:30
@frahlg
frahlg merged commit b1b3469 into master Oct 13, 2026
18 checks passed
@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-13T08:36:50.261949Z 0ff1837 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: 0ff1837088

ℹ️ 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".

s.handle("GET /api/cloud", Read, s.handleCloudStatus)
s.handle("GET /api/cloud/preview", Read, s.handleCloudPreview)
s.handle("POST /api/cloud/grant", Configure, s.handleCloudGrant, NoStepUp)
s.handle("DELETE /api/cloud/grant", Configure, s.handleCloudRevoke, NoStepUp)

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 Let cloud revocation bypass the outer LAN-auth gate

When api.lan_auth is enabled, a non-loopback LAN request without the house password is rejected by Authenticate before handleCloudRevoke reaches cloudGate, because DELETE requests require mutation protection and these paths are not LAN-auth exemptions. Consequently the documented and tested intent that any local LAN client can narrow access by revoking the grant also fails for DELETE /api/cloud/stats; the current tests call the handlers directly and therefore miss the full middleware path.

Useful? React with 👍 / 👎.

Comment on lines +42 to +46
if settle > 0 {
time.Sleep(settle)
}
f, ok := facts(loadpointID)
if !ok || !f.Plugged || (f.LevelKnown && f.HasGoal) {

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 Bind delayed plug-in checks to the originating session

The 90-second sleeper remembers only the loadpoint ID, so an unplug followed by a replug before it wakes makes the old goroutine inspect the new session and send its notification. The new plug-in also starts its own sleeper, which can produce a second notification for that same session once the 60-second default cooldown has elapsed—for example, unplugging 70 seconds after the first connection and immediately reconnecting. Capture a session generation or cancel the pending check on disconnect so a prior session cannot notify about a later one.

Useful? React with 👍 / 👎.

Comment on lines +348 to +350
l.mu.Unlock()

key, err := createKey(l.opts.KeyPath)

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 Serialize first-grant creation

If two owner sessions submit the first grant concurrently, both can observe l.grant == nil and proceed after this unlock. They then share cloud.key.tmp: one request can rename bytes written by the other while retaining its own generated key in memory, persist a fingerprint for that in-memory key, and leave a different key on disk. The link works until restart, when the fingerprint mismatch silently turns FTW Cloud off; a concurrent revoke can likewise return before the still-running grant turns the service back on. Keep first-grant creation and installation under a dedicated operation lock or recheck state before committing.

Useful? React with 👍 / 👎.

frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 13, 2026
srcfl/ftw#1607 landed as b1b34697. go/protocol there is byte for byte
what a9e6f66f on the claude/cloud-c3c4-core branch held, so nothing
else changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 13, 2026
#1607 added ts to the event body and its allowlist. #1604 then brought
in docs/protocol-cloud.md without that row, so the test that compares the
allowlist with the document failed on master. CI did not see it because
#1604 changed only docs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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