Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/cloud-statistics-terms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"ftw": minor
---

Include project statistics in the updated FTW Cloud terms. Local-only users
get a separate question with Yes suggested; nothing starts before an answer.
Statistics-only sharing grants no app or assistant remote access. Turning
Cloud off ends remote access and remembers that choice across restarts.
Hourly collection still waits for the signed C8 review and service approval.
80 changes: 54 additions & 26 deletions docs/adr/0008-ftw-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,33 +154,46 @@ cases.

## Statistics

**The start flow has two switches.** The first turns FTW Cloud on; the iPhone
app needs it, and it lists what leaves the house. The second turns statistics
on; it is off by default, and the app works the same either way. A change to
purpose or fields needs a new policy version and fresh consent.
**Product amendment, 17 October 2026.** Everyone who chooses FTW Cloud
contributes to project statistics. The updated Cloud terms name this scope;
there is no separate statistics opt-out within Cloud. Existing users must
accept the new terms before the approved policy can admit their home.
Owners who decline use the box locally without Cloud.

Local-only users get a separate question, with Yes as the suggested action.
Nothing starts before an active answer. No leaves local control working.
This statistics-only link publishes no phone keys, pairing codes, Noise key,
read grant, live state or remote access. It reuses the signed upload path.
A policy or purpose change requires a new version and a fresh answer.
This product decision does not establish a lawful basis; C8 stays off until
that question and the DPIA have been reviewed and signed.

- **Purpose.** To learn how homes with FTW use and shift energy, to improve
FTW's planning and defaults, and to publish figures about homes as a group.
Not for advertising, decisions about one home, or handing rows to anyone.
- **Fields.** Per closed hour, the site's energy totals in Wh, rounded to
10 Wh, under the ledger's flow names; the price zone; which of battery,
solar and charger the site has; the policy version. Nothing finer than an
hour, and no names, addresses, IP addresses, serials, coordinates, VINs,
config or logs.
- **Fields.** Recorded closed-hour grid import/export, solar generation,
battery and vehicle charge/discharge; configured battery capacity,
battery/charger power limits and solar rating. Rounded to 10 Wh or W.
Missing values stay absent. No names, addresses, location, device ids,
prices, free text, configuration documents or logs.
- **`statsId`.** 128 random bits drawn by the box at consent, derived from
nothing else. A new consent draws a new one.
- **Retention.** Rows go 13 months after the hour they describe. Withdrawal or
a purge deletes them within 24 hours. Published aggregates stay, and the
- **Retention.** Rows go 13 months after the hour they describe. Leaving Cloud, stopping local sharing or
a purge deletes unpublished rows when Cloud receives the signed request.
An offline box sends it on reconnection. Published aggregates stay, and the
consent screen says so.
- **Cell rules.** At least 20 homes per figure, nothing finer than a price
zone or an hour, and no home above 30 % of a cell's sum. Code applies the
rules; no person reads raw rows.
- **Cell rules.** At least 20 homes per figure, no per-home output, and no
home above 30 % of a cell's sum. Code applies the rules. No staff route
reads raw rows; full host administrators can read the database.
- **Partners.** Homes under a partner's control stay out unless that
partner's contract allows them in. Nova keeps B2B partner telemetry.
- **Law.** The service links `statsId` to the home so a purge can reach it,
which makes the rows personal data (CJEU C-413/23 P). Serving the owner's phones
is necessary for the service asked for and meets the exemption in LEK
9 kap. 28 §; statistics are not, so they need consent. A lawyer confirms.
- **Law.** The service links `statsId` to the home for deletion, so these
rows are personal data. The required Cloud model needs its own documented
lawful-basis assessment. Writing it in terms does not establish contractual
necessity or make conditional consent freely given. Local sharing proposes
informed, separate consent. A legal review must settle both paths, other
household members and terminal-access rules before activation. The review
draft lives in `ftw-cloud/privacy/project-statistics-dpia.md`.

## Security and retention

Expand Down Expand Up @@ -262,7 +275,9 @@ later step stands on it and ships on its own.
| C7 Live Activity | Signed state in the extension view | A forged state shows "Open FTW" |
| C8 Statistics | Ingest, backfill, cell rules | DPIA signed; withdrawal purges within 24 hours |

The statistics switch ships with the start flow in C4. The box records consent
The original separate Cloud statistics switch is replaced by the 17 October
product amendment above. The Cloud terms and the local question ship with the
start flow. The box records consent
and `statsId` from then on. The October project policy below needs new
consent and sends only later hours; it does not backfill the earlier gap. The lawyer also confirms that order: if the DPIA in C8 changes the
rules, the app asks again. A public launch needs C2–C4 and the legal work.
Expand Down Expand Up @@ -296,13 +311,26 @@ rules, the app asks again. A public launch needs C2–C4 and the legal work.

### Project statistics policy, October 2026

`ftw-project-stats-2026-10-v1` adds configured battery capacity, flexible
power and solar ratings to the closed-hour energy scope. This needs fresh
consent; an earlier statistics choice sends none of these fields. The owner
can read the policy through `GET /api/cloud/stats-policy`, then accept its
exact id through the existing owner-only statistics API. Stats stay off by
default. Changing the policy rotates the random stats id and starts a new
consent window. Hours before that window never contribute.
`ftw-project-stats-2026-10-v2` covers configured capacities, flexible power,
solar rating and closed-hour energy. `ftw-cloud-2026-10-v2` includes that scope
for every Cloud home. An owner reads `GET /api/cloud/stats-policy`, then
accepts the Cloud terms through `POST /api/cloud/grant`. Core records the
scope, random stats id and start time in the same grant. An older Cloud
answer or saved statistics checkbox does not accept this version.

A local owner can instead accept the statistics policy through
`POST /api/cloud/stats`, without granting readable state or remote access.
`DELETE /api/cloud/stats` stops that local contribution. Within Cloud,
statistics cannot be turned off alone; `DELETE /api/cloud` ends Cloud,
requests home deletion and remembers the off choice across restarts.
Phones can need pairing again after the home key is deleted. An app user
who declines its terms pauses Cloud on that phone and keeps its local keys.

The daily anonymous report also waits for the current project choice.
Its saved config switch cannot bypass a missing answer or a local No.
A new policy rotates the stats id and starts a new reporting window. An
unset wall clock starts that window only once time is known; no earlier
hours are filled in.

The public view counts installations reporting in the last 24 closed hours.
It sums the recorded hours, which may be incomplete; it does not claim all
Expand Down
17 changes: 17 additions & 0 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ every call.
| `purposes` | [text] | A subset of `cloud_purposes`: `serve` while the copy is on, and `stats` while the owner contributes to statistics. Empty while the copy is off or being withdrawn |
| `statsId` | bstr, 16 B, optional | Random id for the separately accepted statistics policy; omitted when off |
| `statsPolicy` | text, optional | Exact policy the owner accepted |
| `termsPolicy` | text, optional | Cloud terms accepted by the owner; the current Cloud policy includes statistics |
| `statsSinceMs` | uint, optional | Consent start in UTC milliseconds; only later closed hours may contribute |
| `controller` | text | The data controller: `sourceful` unless a partner pairing flow sets another. No such flow exists yet |

Expand Down Expand Up @@ -1627,3 +1628,19 @@ constants, so `srcfl/ftw-cloud` need not spell them by hand.
| Statistics rows kept | 13 months from the hour they describe |
| Rule N clock lead allowed | 2 s |
| Rule U drift margin, lifetime | 0.01 % of the time since learned; 24 hours |


### Cloud terms and local statistics, policy version 2

`ftw-cloud-2026-10-v2` includes `ftw-project-stats-2026-10-v2` for every Cloud
home. `Grant.termsPolicy` carries the owner-accepted terms. Once the operator
approves collection, Cloud admits only that bundled grant or a statistics-only
roster. Old terms are not silently upgraded; they need an owner answer.
Restoring a roster after restart applies the same rule.

A statistics-only roster names the statistics policy as its terms policy,
contains the stats purpose alone and has no read scopes, devices, pending
codes, Noise key or browser subscriptions. It grants no remote access. Core
also rejects tunnels locally in that mode and emits no live or event data.
Collection stays off until the signed C8 review; the default operator setting
is empty. Withdrawal and retention continue while it is paused.
3 changes: 2 additions & 1 deletion go/cmd/ftw/fleet_ping.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sync"
"time"

"github.com/srcfl/ftw/go/internal/cloudlink"
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/drivers"
"github.com/srcfl/ftw/go/internal/fleetping"
Expand Down Expand Up @@ -76,7 +77,7 @@ func startFleetPing(
installedAt := firstSeen(st, time.Now())

pinger, err := fleetping.New(fleetping.Options{
Enabled: fleetPingEnabled(cfg, cfgMu),
Enabled: func() bool { return fleetPingEnabled(cfg, cfgMu)() && cloudlink.StatisticsChoice(st) },
Facts: func() fleetping.Facts {
// Drivers can be installed without restarting FTW. Read the signed
// catalogue at the same time as the config so tomorrow's report and
Expand Down
21 changes: 21 additions & 0 deletions go/internal/api/api_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ type CloudGrant interface {
Revoke(by string) error
Stats(cloudlink.StatsRequest) error
StopStats(by string) error
End(by string) error
}

func (s *Server) cloudRoutes() {
s.handle("GET /api/cloud", Read, s.handleCloudStatus)
s.handle("DELETE /api/cloud", Configure, s.handleCloudEnd, NoStepUp)
s.handle("GET /api/cloud/preview", Read, s.handleCloudPreview)
s.handle("GET /api/cloud/stats-policy", Read, s.handleCloudStatsPolicy)
s.handle("POST /api/cloud/grant", Configure, s.handleCloudGrant, NoStepUp)
Expand Down Expand Up @@ -204,6 +206,10 @@ func (s *Server) handleCloudStopStats(w http.ResponseWriter, r *http.Request) {
return
}
if err := link.StopStats(cloudCaller(r)); err != nil {
if errors.Is(err, cloudlink.ErrCloudNeedsStatistics) {
writeAppLinkError(w, http.StatusConflict, err.Error())
return
}
writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics off")
return
}
Expand All @@ -228,3 +234,18 @@ func (s *Server) CloudLoadpoints(now time.Time) (states []loadpoint.State, feedb
s.decorateLoadpointsWithPlan(states)
return states, s.controlFeedback(now), true
}

func (s *Server) handleCloudEnd(w http.ResponseWriter, r *http.Request) {
if !s.cloudGate(w, r, false) {
return
}
link, ok := s.cloudLink(w)
if !ok {
return
}
if err := link.End(cloudCaller(r)); err != nil {
writeAppLinkError(w, http.StatusInternalServerError, "could not stop FTW Cloud")
return
}
writeJSON(w, http.StatusOK, link.Status())
}
15 changes: 15 additions & 0 deletions go/internal/api/api_cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func (c *stubCloud) Grant(req cloudlink.GrantRequest) error {
c.granted = append(c.granted, req)
return c.err
}
func (c *stubCloud) End(by string) error { return c.Revoke(by) }

func (c *stubCloud) Revoke(by string) error {
c.revoked = append(c.revoked, by)
return nil
Expand Down Expand Up @@ -281,6 +283,7 @@ func TestAViewerReachesNoCloudRoute(t *testing.T) {
{http.MethodGet, "/api/cloud/preview", "", s.handleCloudPreview},
{http.MethodPost, "/api/cloud/grant", grantBody, s.handleCloudGrant},
{http.MethodDelete, "/api/cloud/grant", "", s.handleCloudRevoke},
{http.MethodDelete, "/api/cloud", "", s.handleCloudEnd},
{http.MethodPost, "/api/cloud/stats", statsBody, s.handleCloudStats},
{http.MethodDelete, "/api/cloud/stats", "", s.handleCloudStopStats},
} {
Expand Down Expand Up @@ -386,3 +389,15 @@ func (k *cloudKV) SaveConfig(key, value string) error {
k.m[key] = value
return nil
}

func TestWholeCloudOffUsesTheExistingNarrowingAccessRule(t *testing.T) {
for _, req := range []*http.Request{sessionRequest(http.MethodDelete, "/api/cloud", ""), localRequest(http.MethodDelete, "/api/cloud", "")} {
c := &stubCloud{}
s := New(&Deps{Cloud: c})
w := httptest.NewRecorder()
s.handleCloudEnd(w, req)
if w.Code != http.StatusOK || len(c.revoked) != 1 {
t.Fatal("whole Cloud off failed", w.Code)
}
}
}
15 changes: 10 additions & 5 deletions go/internal/api/cloud_statistics.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (
"time"

"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/protocol"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

// CloudStatistics contains only the fields named by the separate statistics
// consent. Configuration and ledger ids never cross this boundary.
// CloudStatistics contains only the fields named by the versioned project
// statistics terms. Configuration and ledger ids never cross this boundary.
func (s *Server) CloudStatistics(ctx context.Context, now time.Time) (cloudwire.Stats, bool) {
hour := now.UTC().Truncate(time.Hour).Add(-time.Hour)
out := cloudwire.Stats{HourMS: hour.UnixMilli(), Metrics: map[string]int64{}}
Expand Down Expand Up @@ -128,13 +129,17 @@ func (s *Server) handleCloudStatsPolicy(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{
"available": available,
"policy": cloudwire.StatsPolicy,
"cloud_scopes": protocol.CloudScopes,
"cloud_policy": cloudwire.CloudTermsPolicy,
"cloud_includes_statistics": true,
"standalone": true,
"purpose": "Publish group totals for FTW use and energy; improve planning. No ads or household decisions.",
"fields": cloudwire.StatsMetricKeys,
"meaning": "One opted-in installation, configured flexible power and solar ratings, battery capacity, and recorded closed-hour energy. Configured limits do not prove physical control. Missing readings are unknown.",
"meaning": "One reporting installation, configured flexible power and solar ratings, battery capacity, and recorded closed-hour energy. Configured limits do not prove physical control. Missing readings are unknown.",
"resolution": "Closed UTC hours, energy rounded to 10 Wh, capacities and limits rounded to 10 Wh or W. No names, addresses, device ids or location.",
"retention": "13 months. Switching statistics off deletes unpublished household rows; published group totals remain.",
"retention": "13 months. Leaving Cloud or stopping local sharing deletes unpublished household rows; published group totals remain.",
"publication_minimum_homes": cloudwire.StatsMinimumHomes,
"publication_max_home_share": 0.3,
"controller": "sourceful", "default": "off", "optional": true,
"controller": "sourceful", "local_default": "ask_yes", "local_optional": true, "cloud_required": true,
})
}
Loading
Loading